feat/rafactor #30
@@ -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
|
||||
|
||||
1
AGENT.md
1
AGENT.md
@@ -13,5 +13,6 @@
|
||||
|
||||
- Не запускай `cargo run`, `cargo build`, `cargo test`, `cargo check` без прямой команды пользователя.
|
||||
- Не коммить изменения, пока пользователь не попросит.
|
||||
- Если пользователь попросил тесты/коммит/план до конца, используй quality gate из [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||
- После функциональной правки дай короткий ручной сценарий проверки.
|
||||
- Обновляй [CONTEXT.md](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<T: TdClientTrait>`, чтобы тесты могли использовать `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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- Делай одну логическую правку за раз.
|
||||
|
||||
@@ -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<TextEntity>) {
|
||||
@@ -9,27 +11,27 @@ fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||
(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
|
||||
))
|
||||
|
||||
250
docs/REFACTOR_PLAN.md
Normal file
250
docs/REFACTOR_PLAN.md
Normal file
@@ -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<Mutex<...>>` 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.
|
||||
@@ -33,17 +33,12 @@ pub fn acquire_lock(account_name: &str) -> Result<File, String> {
|
||||
|
||||
// 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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -132,9 +132,9 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
||||
_ => 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<T: TdClientTrait> MessageMethods<T> for App<T> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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<T: TdClientTrait>(
|
||||
}
|
||||
}
|
||||
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<T: TdClientTrait>(
|
||||
{
|
||||
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<T: TdClientTrait>(app: &mut App<T>,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка команды ViewImage — только фото
|
||||
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
|
||||
// Закомментировано - будет реализовано в Этапе 4
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
|
||||
// Закомментировано - будет реализовано в Этапе 4
|
||||
}
|
||||
*/
|
||||
|
||||
// TODO (Этап 4): Функция _download_and_expand будет переписана
|
||||
/*
|
||||
#[cfg(feature = "images")]
|
||||
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
|
||||
// Закомментировано - будет реализовано в Этапе 4
|
||||
}
|
||||
*/
|
||||
|
||||
328
src/input/handlers/chat/media.rs
Normal file
328
src/input/handlers/chat/media.rs
Normal file
@@ -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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,4 +74,3 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,16 +228,18 @@ pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
|
||||
}
|
||||
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
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<char> = name_input.chars().collect();
|
||||
chars.remove(*cursor_position - 1);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position -= 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.insert(*cursor_position, c);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position += 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима профиля пользователя/чата
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Модалку подтверждения выхода из группы (двухшаговая)
|
||||
/// - Навигацию по действиям профиля (Up/Down)
|
||||
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
||||
/// - Выход из режима профиля (Esc)
|
||||
pub async fn handle_profile_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
// Обработка подтверждения выхода из группы
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
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;
|
||||
|
||||
76
src/input/handlers/modal/account.rs
Normal file
76
src/input/handlers/modal/account.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
/// Обработка ввода в модалке переключения аккаунтов.
|
||||
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
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<char> = name_input.chars().collect();
|
||||
chars.remove(*cursor_position - 1);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position -= 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.insert(*cursor_position, c);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position += 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
52
src/input/handlers/modal/delete.rs
Normal file
52
src/input/handlers/modal/delete.rs
Normal file
@@ -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<T: TdClientTrait>(app: &mut App<T>, 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 => {}
|
||||
}
|
||||
}
|
||||
32
src/input/handlers/modal/pinned.rs
Normal file
32
src/input/handlers/modal/pinned.rs
Normal file
@@ -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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
136
src/input/handlers/modal/profile.rs
Normal file
136
src/input/handlers/modal/profile.rs
Normal file
@@ -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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||
if confirmation_step > 0 {
|
||||
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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/input/handlers/modal/reactions.rs
Normal file
54
src/input/handlers/modal/reactions.rs
Normal file
@@ -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<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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<Command>,
|
||||
/// ) -> 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<Command>,
|
||||
) -> 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<Command>,
|
||||
) -> 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<Command>,
|
||||
) -> 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<Command>,
|
||||
) -> 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<Command>,
|
||||
) -> 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<dyn KeyHandler>)>,
|
||||
}
|
||||
|
||||
impl KeyHandlerChain {
|
||||
/// Создаёт новую цепочку
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет обработчик в цепочку
|
||||
pub fn add<H: KeyHandler + 'static>(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<Command>,
|
||||
) -> 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
|
||||
}
|
||||
}
|
||||
16
src/main.rs
16
src/main.rs
@@ -222,15 +222,17 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
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
|
||||
|
||||
@@ -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<F>(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();
|
||||
}
|
||||
|
||||
@@ -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<ChatInfo> {
|
||||
&mut self.chat_manager.chats
|
||||
pub fn update_chats<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>) -> R,
|
||||
{
|
||||
updater(&mut self.chat_manager.chats)
|
||||
}
|
||||
|
||||
pub fn update_chat<F>(&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<FolderInfo> {
|
||||
&mut self.chat_manager.folders
|
||||
pub fn update_folders<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>) -> R,
|
||||
{
|
||||
updater(&mut self.chat_manager.folders)
|
||||
}
|
||||
|
||||
pub fn set_folders(&mut self, folders: Vec<FolderInfo>) {
|
||||
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<MessageInfo> {
|
||||
&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<MessageInfo>) {
|
||||
self.message_manager.current_chat_messages = messages;
|
||||
}
|
||||
|
||||
pub fn update_current_chat_messages<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>) -> R,
|
||||
{
|
||||
updater(&mut self.message_manager.current_chat_messages)
|
||||
}
|
||||
|
||||
pub fn update_current_chat_message<F>(&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<ChatId> {
|
||||
@@ -498,8 +592,10 @@ impl TdClient {
|
||||
&self.user_cache.pending_user_ids
|
||||
}
|
||||
|
||||
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<crate::types::UserId> {
|
||||
&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<F, R>(&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) => {
|
||||
// Обновляем состояние сетевого соединения
|
||||
|
||||
@@ -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<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>),
|
||||
{
|
||||
TdClient::update_chats(self, updater);
|
||||
}
|
||||
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>),
|
||||
{
|
||||
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<ChatId> {
|
||||
self.current_chat_id()
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
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<MessageInfo>) {
|
||||
TdClient::set_current_chat_messages(self, messages);
|
||||
}
|
||||
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>),
|
||||
{
|
||||
TdClient::update_current_chat_messages(self, updater);
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.set_current_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
self.set_current_pinned_message(msg)
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
self.pending_view_messages()
|
||||
}
|
||||
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
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<F>(&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<String, String> {
|
||||
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<MessageInfo> {
|
||||
self.message_manager.current_chat_messages.to_vec()
|
||||
}
|
||||
|
||||
fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.current_chat_id()
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
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<MessageId>)] {
|
||||
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<ChatInfo> {
|
||||
self.chats_mut()
|
||||
}
|
||||
|
||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo> {
|
||||
self.folders_mut()
|
||||
}
|
||||
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
|
||||
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<MessageInfo>) {
|
||||
*self.current_chat_messages_mut() = messages;
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.set_current_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
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<MessageId>) {
|
||||
self.enqueue_pending_view_messages(chat_id, message_ids);
|
||||
}
|
||||
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId> {
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>)` - Список доступных 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<String> = Vec::new();
|
||||
|
||||
// let emojis: Vec<String> = 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ProfileInfo, String>;
|
||||
|
||||
// ============ 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<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>);
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>);
|
||||
}
|
||||
|
||||
/// 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<ChatId>;
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||
fn push_message(&mut self, msg: MessageInfo);
|
||||
fn clear_current_chat_messages(&mut self);
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>);
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>);
|
||||
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<F>(&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<String, String>;
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
||||
}
|
||||
|
||||
// ============ 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<i64, String>;
|
||||
fn auth_state(&self) -> &AuthState;
|
||||
fn chats(&self) -> &[ChatInfo];
|
||||
fn folders(&self) -> &[FolderInfo];
|
||||
fn current_chat_messages(&self) -> Vec<MessageInfo>;
|
||||
fn current_chat_id(&self) -> Option<ChatId>;
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||
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<ChatInfo>;
|
||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo>;
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo>;
|
||||
fn clear_current_chat_messages(&mut self);
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||
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<MessageId>);
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId>;
|
||||
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<T> TdClientTrait for T where
|
||||
T: AuthClient
|
||||
+ ChatClient
|
||||
+ ChatActionClient
|
||||
+ MessageClient
|
||||
+ UserClient
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ NotificationClient
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
});
|
||||
// Обновляем 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 - обновление черновика сообщения в чате.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &App<T>,
|
||||
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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
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<WrappedLine> {
|
||||
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<char> = 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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
let content_width = area.width.saturating_sub(2) as usize;
|
||||
|
||||
// Messages с группировкой по дате и отправителю
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// ID выбранного сообщения для подсветки
|
||||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||||
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
||||
let mut selected_msg_line: Option<usize> = None;
|
||||
|
||||
// ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений.
|
||||
// Теперь загружаем только видимые изображения во втором проходе (см. ниже).
|
||||
|
||||
// Собираем информацию о развёрнутых изображениях (для второго прохода)
|
||||
#[cfg(feature = "images")]
|
||||
let mut deferred_images: Vec<components::DeferredImageRender> = 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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
// Модальное окно просмотра изображения (приоритет выше всех)
|
||||
#[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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
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
|
||||
{
|
||||
|
||||
55
src/ui/messages/header.rs
Normal file
55
src/ui/messages/header.rs
Normal file
@@ -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<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &App<T>,
|
||||
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);
|
||||
}
|
||||
286
src/ui/messages/list.rs
Normal file
286
src/ui/messages/list.rs
Normal file
@@ -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<WrappedLine> {
|
||||
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<char> = 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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
let content_width = area.width.saturating_sub(2) as usize;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||||
let mut selected_msg_line: Option<usize> = None;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
let mut deferred_images: Vec<components::DeferredImageRender> = 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<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &mut App<T>,
|
||||
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());
|
||||
}
|
||||
38
src/ui/messages/pinned.rs
Normal file
38
src/ui/messages/pinned.rs
Normal file
@@ -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<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
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);
|
||||
}
|
||||
@@ -56,12 +56,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
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() {
|
||||
|
||||
@@ -80,12 +80,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Local>> {
|
||||
DateTime::<Utc>::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<String>;
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate>;
|
||||
}
|
||||
|
||||
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<String> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).format(format).to_string())
|
||||
}
|
||||
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
|
||||
DateTime::<Utc>::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<FixedOffset>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FixedLocalTime {
|
||||
fn new(offset: FixedOffset, now_timestamp: i32) -> Self {
|
||||
let now = DateTime::<Utc>::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<String> {
|
||||
DateTime::<Utc>::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<NaiveDate> {
|
||||
DateTime::<Utc>::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('.'));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ============
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 переменные
|
||||
|
||||
@@ -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 окружения
|
||||
|
||||
@@ -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: Удаление несуществующего сообщения (ничего не происходит)
|
||||
|
||||
@@ -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 поле
|
||||
|
||||
@@ -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 флаг
|
||||
|
||||
@@ -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
|
||||
/// Симулирует полный путь пользователя от запуска до загрузки чатов
|
||||
|
||||
@@ -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: Множественные редактирования одного сообщения
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<MessageInfo>) -> Self {
|
||||
self.messages
|
||||
.entry(chat_id)
|
||||
.or_insert_with(Vec::new)
|
||||
.extend(messages);
|
||||
self.messages.entry(chat_id).or_default().extend(messages);
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MessageId>,
|
||||
},
|
||||
ChatAction {
|
||||
chat_id: ChatId,
|
||||
user_id: UserId,
|
||||
action: String,
|
||||
},
|
||||
MessageInteractionInfo {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
},
|
||||
ConnectionState {
|
||||
state: NetworkState,
|
||||
},
|
||||
ChatReadOutbox {
|
||||
chat_id: ChatId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
},
|
||||
ChatDraftMessage {
|
||||
chat_id: ChatId,
|
||||
draft_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Упрощённый mock TDLib клиента для тестов
|
||||
#[allow(dead_code)]
|
||||
pub struct FakeTdClient {
|
||||
// Данные
|
||||
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
|
||||
pub messages: Arc<Mutex<HashMap<i64, Vec<MessageInfo>>>>,
|
||||
pub folders: Arc<Mutex<Vec<FolderInfo>>>,
|
||||
pub user_names: Arc<Mutex<HashMap<i64, String>>>,
|
||||
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
|
||||
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
|
||||
pub available_reactions: Arc<Mutex<Vec<String>>>,
|
||||
|
||||
// Состояние
|
||||
pub network_state: Arc<Mutex<NetworkState>>,
|
||||
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
|
||||
pub current_chat_id: Arc<Mutex<Option<i64>>>,
|
||||
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
|
||||
pub auth_state: Arc<Mutex<AuthState>>,
|
||||
|
||||
// История действий (для проверки в тестах)
|
||||
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
|
||||
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
|
||||
pub deleted_messages: Arc<Mutex<Vec<DeletedMessages>>>,
|
||||
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
|
||||
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
|
||||
pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids)
|
||||
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action)
|
||||
pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные
|
||||
|
||||
// Update channel для симуляции событий
|
||||
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
||||
|
||||
// Скачанные файлы (file_id -> local_path)
|
||||
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
||||
|
||||
// Настройки поведения
|
||||
pub simulate_delays: bool,
|
||||
pub fail_next_operation: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SentMessage {
|
||||
pub chat_id: i64,
|
||||
pub text: String,
|
||||
pub reply_to: Option<MessageId>,
|
||||
pub reply_info: Option<ReplyInfo>,
|
||||
}
|
||||
|
||||
#[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<MessageId>,
|
||||
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<MessageId>,
|
||||
}
|
||||
|
||||
#[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<TdUpdate>) {
|
||||
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<ChatInfo>) -> 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<MessageInfo>) -> 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<String>) -> Self {
|
||||
*self.available_reactions.lock().unwrap() = reactions;
|
||||
self
|
||||
}
|
||||
|
||||
// ==================== Async TDLib Operations ====================
|
||||
|
||||
/// Загрузить список чатов
|
||||
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, 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<Vec<MessageInfo>, 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<Vec<MessageInfo>, 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<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
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<MessageInfo, String> {
|
||||
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<MessageId>,
|
||||
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<MessageId>,
|
||||
) -> 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<Vec<MessageInfo>, 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<Vec<String>, 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<String, String> {
|
||||
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<ProfileInfo, String> {
|
||||
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<MessageId>) {
|
||||
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<ChatInfo> {
|
||||
self.chats.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить все папки
|
||||
pub fn get_folders(&self) -> Vec<FolderInfo> {
|
||||
self.folders.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить сообщения чата
|
||||
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&chat_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Получить отправленные сообщения
|
||||
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
|
||||
self.sent_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить отредактированные сообщения
|
||||
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
|
||||
self.edited_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить удалённые сообщения
|
||||
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
|
||||
self.deleted_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить пересланные сообщения
|
||||
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
|
||||
self.forwarded_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить поисковые запросы
|
||||
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
|
||||
self.searched_queries.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Получить просмотренные сообщения
|
||||
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
|
||||
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<i64> {
|
||||
*self.current_chat_id.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Установить update channel для получения событий
|
||||
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
|
||||
*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;
|
||||
|
||||
86
tests/helpers/fake_tdclient/builders.rs
Normal file
86
tests/helpers/fake_tdclient/builders.rs
Normal file
@@ -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<TdUpdate>) {
|
||||
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<ChatInfo>) -> 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<MessageInfo>) -> 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<String>) -> Self {
|
||||
*self.available_reactions.lock().unwrap() = reactions;
|
||||
self
|
||||
}
|
||||
}
|
||||
92
tests/helpers/fake_tdclient/inspect.rs
Normal file
92
tests/helpers/fake_tdclient/inspect.rs
Normal file
@@ -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<ChatInfo> {
|
||||
self.chats.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_folders(&self) -> Vec<FolderInfo> {
|
||||
self.folders.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&chat_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
|
||||
self.sent_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
|
||||
self.edited_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
|
||||
self.deleted_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
|
||||
self.forwarded_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
|
||||
self.searched_queries.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
|
||||
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<i64> {
|
||||
*self.current_chat_id.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
*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<i64> = 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<TdUpdate>) {
|
||||
*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();
|
||||
}
|
||||
}
|
||||
458
tests/helpers/fake_tdclient/operations.rs
Normal file
458
tests/helpers/fake_tdclient/operations.rs
Normal file
@@ -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<Vec<ChatInfo>, 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<Vec<MessageInfo>, 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<Vec<MessageInfo>, 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<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
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<MessageInfo, String> {
|
||||
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<MessageId>,
|
||||
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<MessageId>,
|
||||
) -> 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<Vec<MessageInfo>, 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<Vec<String>, 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<String, String> {
|
||||
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<ProfileInfo, String> {
|
||||
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<MessageId>) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
201
tests/helpers/fake_tdclient/state.rs
Normal file
201
tests/helpers/fake_tdclient/state.rs
Normal file
@@ -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<i64>)>;
|
||||
pub type PendingViewMessages = Vec<(ChatId, Vec<MessageId>)>;
|
||||
|
||||
/// Update events from TDLib, simplified for tests.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TdUpdate {
|
||||
NewMessage {
|
||||
chat_id: ChatId,
|
||||
message: Box<MessageInfo>,
|
||||
},
|
||||
MessageContent {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
},
|
||||
DeleteMessages {
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
},
|
||||
ChatAction {
|
||||
chat_id: ChatId,
|
||||
user_id: UserId,
|
||||
action: String,
|
||||
},
|
||||
MessageInteractionInfo {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
},
|
||||
ConnectionState {
|
||||
state: NetworkState,
|
||||
},
|
||||
ChatReadOutbox {
|
||||
chat_id: ChatId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
},
|
||||
ChatDraftMessage {
|
||||
chat_id: ChatId,
|
||||
draft_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Simplified mock TDLib client for tests.
|
||||
#[allow(dead_code)]
|
||||
pub struct FakeTdClient {
|
||||
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
|
||||
pub messages: Arc<Mutex<HashMap<i64, Vec<MessageInfo>>>>,
|
||||
pub folders: Arc<Mutex<Vec<FolderInfo>>>,
|
||||
pub user_names: Arc<Mutex<HashMap<i64, String>>>,
|
||||
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
|
||||
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
|
||||
pub available_reactions: Arc<Mutex<Vec<String>>>,
|
||||
|
||||
pub network_state: Arc<Mutex<NetworkState>>,
|
||||
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
|
||||
pub current_chat_id: Arc<Mutex<Option<i64>>>,
|
||||
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
|
||||
pub auth_state: Arc<Mutex<AuthState>>,
|
||||
|
||||
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
|
||||
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
|
||||
pub deleted_messages: Arc<Mutex<Vec<DeletedMessages>>>,
|
||||
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
|
||||
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
|
||||
pub viewed_messages: Arc<Mutex<ViewedMessages>>,
|
||||
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>,
|
||||
pub pending_view_messages: Arc<Mutex<PendingViewMessages>>,
|
||||
|
||||
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
||||
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
||||
|
||||
pub simulate_delays: bool,
|
||||
pub fail_next_operation: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SentMessage {
|
||||
pub chat_id: i64,
|
||||
pub text: String,
|
||||
pub reply_to: Option<MessageId>,
|
||||
pub reply_info: Option<ReplyInfo>,
|
||||
}
|
||||
|
||||
#[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<MessageId>,
|
||||
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<MessageId>,
|
||||
}
|
||||
|
||||
#[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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>),
|
||||
{
|
||||
updater(&mut self.chats.lock().unwrap());
|
||||
}
|
||||
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>),
|
||||
{
|
||||
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<Vec<MessageInfo>, 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<ChatId> {
|
||||
self.get_current_chat_id().map(ChatId::new)
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
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<MessageInfo>) {
|
||||
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||
self.messages.lock().unwrap().insert(chat_id, messages);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>),
|
||||
{
|
||||
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<ChatId>) {
|
||||
*self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64());
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
*self.current_pinned_message.lock().unwrap() = msg;
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
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<i64> = 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<UserCache> = OnceLock::new();
|
||||
EMPTY_CACHE.get_or_init(|| UserCache::new(0))
|
||||
}
|
||||
|
||||
fn update_user_cache<F>(&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<String, String> {
|
||||
FakeTdClient::download_file(self, file_id).await
|
||||
}
|
||||
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||
// 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<i64, String> {
|
||||
Ok(12345) // Fake user ID
|
||||
Ok(12345)
|
||||
}
|
||||
|
||||
fn auth_state(&self) -> &AuthState {
|
||||
// Can't return reference from Arc<Mutex>, 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<AuthState> = OnceLock::new();
|
||||
@@ -222,133 +337,24 @@ impl TdClientTrait for FakeTdClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn chats(&self) -> &[ChatInfo] {
|
||||
// FakeTdClient uses Arc<Mutex>, 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<MessageInfo> {
|
||||
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<ChatId> {
|
||||
self.get_current_chat_id().map(ChatId::new)
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
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<MessageId>)] {
|
||||
&[]
|
||||
}
|
||||
|
||||
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<UserCache> = 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<ChatInfo> {
|
||||
// Can't return mutable reference from Arc<Mutex>
|
||||
// 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<FolderInfo> {
|
||||
panic!("folders_mut not supported for FakeTdClient")
|
||||
}
|
||||
fn sync_notification_muted_chats(&mut self) {}
|
||||
}
|
||||
|
||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
|
||||
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<MessageInfo>) {
|
||||
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<ChatId>) {
|
||||
*self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64());
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
*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<MessageId>) {
|
||||
self.pending_view_messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((chat_id, message_ids));
|
||||
}
|
||||
|
||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId> {
|
||||
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) {}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: Подгрузка старых сообщений при скролле вверх
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
// После отмены видим все чаты
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user