feat/rafactor #30

Merged
killingdruid merged 8 commits from feat/rafactor into main 2026-05-17 16:02:32 +00:00
69 changed files with 3253 additions and 3268 deletions

View File

@@ -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

View File

@@ -13,5 +13,6 @@
- Не запускай `cargo run`, `cargo build`, `cargo test`, `cargo check` без прямой команды пользователя.
- Не коммить изменения, пока пользователь не попросит.
- Если пользователь попросил тесты/коммит/план до конца, используй quality gate из [DEVELOPMENT.md](DEVELOPMENT.md).
- После функциональной правки дай короткий ручной сценарий проверки.
- Обновляй [CONTEXT.md](CONTEXT.md), только если изменились статус, риск, архитектурное решение или следующий шаг.

View File

@@ -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.

View File

@@ -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

View File

@@ -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
- Делай одну логическую правку за раз.

View File

@@ -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));
});
}

View File

@@ -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
))

View File

@@ -1,3 +0,0 @@
fn main() {
tdlib_rs::build::build(None);
}

250
docs/REFACTOR_PLAN.md Normal file
View 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.

View File

@@ -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!(

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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
}
*/

View 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));
}
}
}

View File

@@ -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));
}
}

View File

@@ -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);
});
}
}

View File

@@ -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;

View File

@@ -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;

View 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;
}
}
_ => {}
},
}
}

View 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 => {}
}
}

View 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();
}
}
_ => {}
}
}

View 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;
}
}
}

View 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;
}
_ => {}
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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) => {
// Обновляем состояние сетевого соединения

View File

@@ -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)

View File

@@ -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();
}
}
});
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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(),
]
);
}
}

View File

@@ -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
{
}

View File

@@ -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 - обновление черновика сообщения в чате.

View File

@@ -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() {

View File

@@ -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(&current_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
View 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
View 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(&current_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
View 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);
}

View File

@@ -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() {

View File

@@ -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));
}
}

View File

@@ -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('.'));

View File

@@ -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());
}
}
}

View File

@@ -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 ============

View File

@@ -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();

View File

@@ -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 переменные

View File

@@ -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 окружения

View File

@@ -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: Удаление несуществующего сообщения (ничего не происходит)

View File

@@ -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 поле

View File

@@ -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 флаг

View File

@@ -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
/// Симулирует полный путь пользователя от запуска до загрузки чатов

View File

@@ -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: Множественные редактирования одного сообщения

View File

@@ -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();

View File

@@ -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
}

View File

@@ -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;

View 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
}
}

View 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();
}
}

View 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,
});
}
}

View 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)),
}
}
}

View File

@@ -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) {}
}

View File

@@ -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};

View File

@@ -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),

View File

@@ -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() {

View File

@@ -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: Подгрузка старых сообщений при скролле вверх

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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};

View File

@@ -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();
// После отмены видим все чаты

View File

@@ -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");
}