Compare commits

...

28 Commits

Author SHA1 Message Date
Mikhail Kilin
dfd4184039 fix: keep selection on last/first message instead of deselecting
Some checks failed
ci/woodpecker/pr/check Pipeline failed
When pressing down on the last message or up on the first message in
chat navigation, stay on the current message instead of exiting
message selection mode.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:35:06 +03:00
044b859cec Merge pull request 'ci/woodpecker-checks' (#21) from ci/woodpecker-checks into main 2026-02-22 15:12:46 +00:00
Mikhail Kilin
51e7941668 chore: remove unused GitHub Actions workflow
All checks were successful
ci/woodpecker/pr/check Pipeline was successful
Woodpecker CI is the active CI system; GitHub Actions never runs on Gitea.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:04:32 +03:00
Mikhail Kilin
3b7ef41cae fix: resolve all 40 clippy warnings (dead_code, unused_imports, lints)
Some checks failed
ci/woodpecker/pr/check Pipeline was successful
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
- Add #[allow(unused_imports)] on pub re-exports used only by lib/tests
- Add #[allow(dead_code)] on public API items unused in binary target
- Fix collapsible_if, redundant_closure, unnecessary_map_or in main.rs
- Prefix unused test variables with underscore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:50:18 +03:00
Mikhail Kilin
166fda93a4 style: fix formatting after clippy changes
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:33:48 +03:00
Mikhail Kilin
d4e1ed1376 fix: resolve all 23 clippy warnings
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:28:50 +03:00
Mikhail Kilin
d9eb61dda7 ci: use rust:latest image (deps require rustc 1.88+)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:14:31 +03:00
Mikhail Kilin
c7865b46a7 ci: bump rust image to 1.85 (edition 2024 support)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:12:14 +03:00
Mikhail Kilin
264f183510 style: auto-format entire codebase with cargo fmt (stable rustfmt.toml)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 17:09:51 +03:00
Mikhail Kilin
2442a90e23 ci: add Woodpecker CI pipeline for PR checks (fmt, clippy, test)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
2026-02-22 16:53:15 +03:00
Mikhail Kilin
48d883a746 Merge branch 'refactor' 2026-02-22 16:52:31 +03:00
7ca9ea29ea Merge pull request 'refactor' (#19) from refactor into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #19
2026-02-05 18:58:39 +00:00
d10dc6599a Merge pull request 'refactor' (#18) from refactor into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #18
2026-02-04 17:08:43 +00:00
0cd477f294 Merge pull request 'refactor: complete UI components - implement message_bubble.rs' (#17) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #17
2026-02-02 00:45:42 +00:00
8855a07ccd Merge pull request 'add_tests' (#16) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #16
2026-02-02 00:19:58 +00:00
9cc63952f4 Merge pull request 'add_tests' (#15) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #15
2026-02-01 15:59:43 +00:00
0a4ab1b40d Merge pull request 'add_tests' (#14) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #14
2026-01-31 23:42:29 +00:00
20f1c470c4 Merge pull request 'add_tests' (#13) from add_tests into main
Some checks failed
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
CI / Check (push) Has been cancelled
Reviewed-on: #13
2026-01-31 23:06:22 +00:00
c2ddb0a449 Merge pull request 'add_tests' (#11) from add_tests into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #11
2026-01-28 22:23:58 +00:00
72a8f3e6b1 Merge pull request 'yet-another-changes' (#10) from yet-another-changes into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled
Reviewed-on: #10
2026-01-27 22:43:12 +00:00
61dc09fd50 Merge pull request 'yet-another-changes' (#9) from yet-another-changes into main
Reviewed-on: #9
2026-01-27 09:12:57 +00:00
86e2b4c804 Merge pull request 'yet-another-changes' (#8) from yet-another-changes into main
Reviewed-on: #8
2026-01-24 22:49:27 +00:00
6c297758a0 Merge pull request 'fixes' (#5) from yet-another-changes into main
Reviewed-on: #5
2026-01-23 23:23:49 +00:00
65a73f35de Merge pull request 'yet-another-changes' (#4) from yet-another-changes into main
Reviewed-on: #4
2026-01-22 12:27:08 +00:00
0f379dc240 Merge pull request 'fixes' (#3) from yet-another-changes into main
Reviewed-on: #3
2026-01-20 23:59:28 +00:00
b81eec55d6 Merge pull request 'fixes' (#2) from yet-another-changes into main
Reviewed-on: #2
2026-01-20 23:29:12 +00:00
652b101571 Merge pull request 'yet-another-changes' (#1) from yet-another-changes into main
Reviewed-on: #1
2026-01-20 11:55:25 +00:00
100 changed files with 1985 additions and 1613 deletions

View File

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

26
.woodpecker/check.yml Normal file
View File

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

View File

@@ -2,6 +2,31 @@
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) ## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS)
### Per-Account Lock File Protection — DONE
Защита от запуска двух экземпляров tele-tui с одним аккаунтом + логирование ошибок TDLib.
**Проблема**: При запуске второго экземпляра с тем же аккаунтом, TDLib не может залочить свою БД. `set_tdlib_parameters` молча падает (`let _ = ...`), и приложение зависает на "Инициализация TDLib...".
**Решение**: Advisory file locks через `fs2` (flock):
- **Lock файл**: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock`
- **Автоматическое освобождение** при crash/SIGKILL (ядро ОС закрывает file descriptors)
- **При старте**: acquire lock ДО `enable_raw_mode()` → ошибка выводится в обычный терминал
- **При переключении аккаунтов**: acquire new → release old → switch (при ошибке — остаёмся на старом)
- **Логирование**: `set_tdlib_parameters` ошибки теперь логируются через `tracing::error!`
**Новые файлы:**
- `src/accounts/lock.rs``acquire_lock()`, `release_lock()`, `account_lock_path()` + 4 теста
**Модифицированные файлы:**
- `Cargo.toml` — зависимость `fs2 = "0.4"`
- `src/accounts/mod.rs``pub mod lock;` + re-exports
- `src/app/mod.rs` — поле `account_lock: Option<File>` в `App<T>`
- `src/main.rs` — acquire lock при старте, lock при переключении аккаунтов, логирование set_tdlib_parameters
- `src/tdlib/client.rs` — логирование set_tdlib_parameters в `recreate_client()`
---
### Photo Albums (Media Groups) — DONE ### Photo Albums (Media Groups) — DONE
Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото. Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.

11
Cargo.lock generated
View File

@@ -1169,6 +1169,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -3383,6 +3393,7 @@ dependencies = [
"crossterm", "crossterm",
"dirs 5.0.1", "dirs 5.0.1",
"dotenvy", "dotenvy",
"fs2",
"image", "image",
"insta", "insta",
"notify-rust", "notify-rust",

View File

@@ -37,6 +37,7 @@ thiserror = "1.0"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1" base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies] [dev-dependencies]
insta = "1.34" insta = "1.34"

View File

@@ -1,6 +1,6 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType}; use tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) { fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string(); let text = "This is bold and italic text with code and a link and mention".to_string();
@@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
let entities = vec![]; let entities = vec![];
c.bench_function("format_simple_text", |b| { c.bench_function("format_simple_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -51,9 +49,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) {
let (text, entities) = create_text_with_entities(); let (text, entities) = create_text_with_entities();
c.bench_function("format_markdown_text", |b| { c.bench_function("format_markdown_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -77,9 +73,7 @@ fn benchmark_format_long_text(c: &mut Criterion) {
} }
c.bench_function("format_long_text_with_100_entities", |b| { c.bench_function("format_long_text_with_100_entities", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }

View File

@@ -1,5 +1,5 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, get_day}; use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day};
fn benchmark_format_timestamp(c: &mut Criterion) { fn benchmark_format_timestamp(c: &mut Criterion) {
c.bench_function("format_timestamp_50_times", |b| { c.bench_function("format_timestamp_50_times", |b| {
@@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) {
}); });
} }
criterion_group!( criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
benches,
benchmark_format_timestamp,
benchmark_format_date,
benchmark_get_day
);
criterion_main!(benches); criterion_main!(benches);

View File

@@ -8,7 +8,10 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
.map(|i| { .map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64)) let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(&format!("User{}", i % 10)) .sender_name(&format!("User{}", i % 10))
.text(&format!("Test message number {} with some longer text to make it more realistic", i)) .text(&format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.date(1640000000 + (i as i32 * 60)); .date(1640000000 + (i as i32 * 60));
if i % 2 == 0 { if i % 2 == 0 {
@@ -24,9 +27,7 @@ fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100); let messages = create_test_messages(100);
c.bench_function("group_100_messages", |b| { c.bench_function("group_100_messages", |b| {
b.iter(|| { b.iter(|| group_messages(black_box(&messages)));
group_messages(black_box(&messages))
});
}); });
} }
@@ -34,9 +35,7 @@ fn benchmark_group_500_messages(c: &mut Criterion) {
let messages = create_test_messages(500); let messages = create_test_messages(500);
c.bench_function("group_500_messages", |b| { c.bench_function("group_500_messages", |b| {
b.iter(|| { b.iter(|| group_messages(black_box(&messages)));
group_messages(black_box(&messages))
});
}); });
} }

View File

@@ -6,15 +6,6 @@ max_width = 100
tab_spaces = 4 tab_spaces = 4
newline_style = "Unix" newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting # Formatting
use_small_heuristics = "Default" use_small_heuristics = "Default"
fn_call_width = 80 fn_call_width = 80

127
src/accounts/lock.rs Normal file
View File

@@ -0,0 +1,127 @@
//! Per-account advisory file locking to prevent concurrent access.
//!
//! Uses `flock` (via `fs2`) for automatic lock release on process crash/SIGKILL.
//! Lock file: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock`
use fs2::FileExt;
use std::fs::{self, File};
use std::path::PathBuf;
/// Returns the lock file path for a given account.
///
/// Path: `{data_dir}/tele-tui/accounts/{name}/tele-tui.lock`
pub fn account_lock_path(account_name: &str) -> PathBuf {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("tele-tui");
path.push("accounts");
path.push(account_name);
path.push("tele-tui.lock");
path
}
/// Acquires an exclusive advisory lock for the given account.
///
/// Creates the lock file and parent directories if needed.
/// Returns the open `File` handle — the lock is held as long as this handle exists.
///
/// # Errors
///
/// Returns an error message if the lock is already held by another process
/// or if the lock file cannot be created.
pub fn acquire_lock(account_name: &str) -> Result<File, String> {
let lock_path = account_lock_path(account_name);
// Ensure parent directory exists
if let Some(parent) = lock_path.parent() {
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)
})?;
file.try_lock_exclusive().map_err(|_| {
format!(
"Аккаунт '{}' уже используется другим экземпляром tele-tui.\n\
Lock-файл: {}",
account_name,
lock_path.display()
)
})?;
Ok(file)
}
/// Explicitly releases the lock by unlocking and dropping the file handle.
///
/// Used during account switching to release the old account's lock
/// before acquiring the new one.
pub fn release_lock(lock_file: File) {
let _ = lock_file.unlock();
drop(lock_file);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lock_path_structure() {
let path = account_lock_path("default");
let path_str = path.to_string_lossy();
assert!(path_str.contains("tele-tui"));
assert!(path_str.contains("accounts"));
assert!(path_str.contains("default"));
assert!(path_str.ends_with("tele-tui.lock"));
}
#[test]
fn test_lock_path_per_account() {
let path1 = account_lock_path("work");
let path2 = account_lock_path("personal");
assert_ne!(path1, path2);
assert!(path1.to_string_lossy().contains("work"));
assert!(path2.to_string_lossy().contains("personal"));
}
#[test]
fn test_acquire_and_release() {
let name = "test-lock-acquire-release";
let lock = acquire_lock(name).expect("first acquire should succeed");
// Second acquire should fail (same process, exclusive lock)
let result = acquire_lock(name);
assert!(result.is_err(), "second acquire should fail");
assert!(
result.unwrap_err().contains("уже используется"),
"error should mention already in use"
);
// Release and re-acquire
release_lock(lock);
let lock2 = acquire_lock(name).expect("acquire after release should succeed");
// Cleanup
release_lock(lock2);
let _ = fs::remove_file(account_lock_path(name));
}
#[test]
fn test_lock_released_on_drop() {
let name = "test-lock-drop";
{
let _lock = acquire_lock(name).expect("acquire should succeed");
// _lock dropped here
}
// After drop, lock should be free
let lock = acquire_lock(name).expect("acquire after drop should succeed");
release_lock(lock);
let _ = fs::remove_file(account_lock_path(name));
}
}

View File

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

View File

@@ -4,8 +4,12 @@
//! Each account has its own TDLib database directory under //! Each account has its own TDLib database directory under
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`. //! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
pub mod lock;
pub mod manager; pub mod manager;
pub mod profile; pub mod profile;
pub use lock::{acquire_lock, release_lock};
#[allow(unused_imports)]
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save}; pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
#[allow(unused_imports)]
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig}; pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,19 @@
//! - search: Search in chats and messages //! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete) //! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod navigation;
pub mod messages;
pub mod compose; pub mod compose;
pub mod search; pub mod messages;
pub mod modal; pub mod modal;
pub mod navigation;
pub mod search;
pub use navigation::NavigationMethods; #[allow(unused_imports)]
pub use messages::MessageMethods;
pub use compose::ComposeMethods; pub use compose::ComposeMethods;
pub use search::SearchMethods; #[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods; pub use modal::ModalMethods;
#[allow(unused_imports)]
pub use navigation::NavigationMethods;
#[allow(unused_imports)]
pub use search::SearchMethods;

View File

@@ -106,10 +106,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) { fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() { if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages { self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
messages,
selected_index: 0,
};
} }
} }
@@ -118,11 +115,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_pinned(&mut self) { fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
selected_index,
messages,
} = &mut self.chat_state
{
if *selected_index + 1 < messages.len() { if *selected_index + 1 < messages.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -138,11 +131,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_pinned(&self) -> Option<&MessageInfo> { fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages { if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages,
selected_index,
} = &self.chat_state
{
messages.get(*selected_index) messages.get(*selected_index)
} else { } else {
None None
@@ -170,10 +159,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_profile_action(&mut self) { fn select_previous_profile_action(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action > 0 { if *selected_action > 0 {
*selected_action -= 1; *selected_action -= 1;
} }
@@ -181,10 +167,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_next_profile_action(&mut self, max_actions: usize) { fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action < max_actions.saturating_sub(1) { if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1; *selected_action += 1;
} }
@@ -192,41 +175,25 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn show_leave_group_confirmation(&mut self) { fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 1; *leave_group_confirmation_step = 1;
} }
} }
fn show_leave_group_final_confirmation(&mut self) { fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 2; *leave_group_confirmation_step = 2;
} }
} }
fn cancel_leave_group(&mut self) { fn cancel_leave_group(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 0; *leave_group_confirmation_step = 0;
} }
} }
fn get_leave_group_confirmation_step(&self) -> u8 { fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
leave_group_confirmation_step,
..
} = &self.chat_state
{
*leave_group_confirmation_step *leave_group_confirmation_step
} else { } else {
0 0
@@ -242,10 +209,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_profile_action(&self) -> Option<usize> { fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &self.chat_state {
selected_action, ..
} = &self.chat_state
{
Some(*selected_action) Some(*selected_action)
} else { } else {
None None
@@ -277,11 +241,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_next_reaction(&mut self) { fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
selected_index, &mut self.chat_state
available_reactions,
..
} = &mut self.chat_state
{ {
if *selected_index + 1 < available_reactions.len() { if *selected_index + 1 < available_reactions.len() {
*selected_index += 1; *selected_index += 1;
@@ -290,11 +251,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_reaction(&self) -> Option<&String> { fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker { if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
available_reactions, &self.chat_state
selected_index,
..
} = &self.chat_state
{ {
available_reactions.get(*selected_index) available_reactions.get(*selected_index)
} else { } else {

View File

@@ -2,8 +2,8 @@
//! //!
//! Handles chat list navigation and selection //! Handles chat list navigation and selection
use crate::app::{App, ChatState, InputMode};
use crate::app::methods::search::SearchMethods; use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list /// Navigation methods for chat list

View File

@@ -51,9 +51,11 @@ pub trait SearchMethods<T: TdClientTrait> {
fn update_search_query(&mut self, new_query: String); fn update_search_query(&mut self, new_query: String);
/// Get index of selected search result /// Get index of selected search result
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>; fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results /// Get all search results
#[allow(dead_code)]
fn get_search_results(&self) -> Option<&[MessageInfo]>; fn get_search_results(&self) -> Option<&[MessageInfo]>;
} }
@@ -71,8 +73,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
fn get_filtered_chats(&self) -> Vec<&ChatInfo> { fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации // Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new() let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
.with_folder(self.selected_folder_id);
if !self.search_query.is_empty() { if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone()); criteria = criteria.with_search(self.search_query.clone());
@@ -113,12 +114,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
} }
fn select_next_search_result(&mut self) { fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat { if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
selected_index,
results,
..
} = &mut self.chat_state
{
if *selected_index + 1 < results.len() { if *selected_index + 1 < results.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -126,12 +122,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
} }
fn get_selected_search_result(&self) -> Option<&MessageInfo> { fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat { if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results,
selected_index,
..
} = &self.chat_state
{
results.get(*selected_index) results.get(*selected_index)
} else { } else {
None None

View File

@@ -5,13 +5,14 @@
mod chat_filter; mod chat_filter;
mod chat_state; mod chat_state;
mod state;
pub mod methods; pub mod methods;
mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode}; pub use chat_state::{ChatState, InputMode};
pub use state::AppScreen; #[allow(unused_imports)]
pub use methods::*; pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile; use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait}; use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
@@ -107,6 +108,7 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последней отправки typing status (для throttling) /// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>, pub last_typing_sent: Option<std::time::Instant>,
// Image support // Image support
#[allow(dead_code)]
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>, pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро) /// Renderer для inline preview в чате (Halfblocks - быстро)
@@ -121,6 +123,9 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последнего рендеринга изображений (для throttling до 15 FPS) /// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>, pub last_image_render_time: Option<std::time::Instant>,
// Account lock
/// Advisory file lock to prevent concurrent access to the same account
pub account_lock: Option<std::fs::File>,
// Account switcher // Account switcher
/// Account switcher modal state (global overlay) /// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>, pub account_switcher: Option<AccountSwitcherState>,
@@ -145,6 +150,7 @@ pub struct App<T: TdClientTrait = TdClient> {
pub last_playback_tick: Option<std::time::Instant>, pub last_playback_tick: Option<std::time::Instant>,
} }
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> { impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client. /// Creates a new App instance with the given configuration and client.
/// ///
@@ -165,9 +171,7 @@ impl<T: TdClientTrait> App<T> {
let audio_cache_size_mb = config.audio.cache_size_mb; let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")] #[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new( let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
config.images.cache_size_mb,
));
#[cfg(feature = "images")] #[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast(); let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")] #[cfg(feature = "images")]
@@ -196,6 +200,8 @@ impl<T: TdClientTrait> App<T> {
search_query: String::new(), search_query: String::new(),
needs_redraw: true, needs_redraw: true,
last_typing_sent: None, last_typing_sent: None,
// Account lock
account_lock: None,
// Account switcher // Account switcher
account_switcher: None, account_switcher: None,
current_account_name: "default".to_string(), current_account_name: "default".to_string(),
@@ -275,11 +281,8 @@ impl<T: TdClientTrait> App<T> {
/// Navigate to next item in account switcher list. /// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) { pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
accounts, &mut self.account_switcher
selected_index,
..
}) = &mut self.account_switcher
{ {
// +1 for the "Add account" item at the end // +1 for the "Add account" item at the end
let max_index = accounts.len(); let max_index = accounts.len();
@@ -372,20 +375,6 @@ impl<T: TdClientTrait> App<T> {
.and_then(|id| self.chats.iter().find(|c| c.id == id)) .and_then(|id| self.chats.iter().find(|c| c.id == id))
} }
// ========== Getter/Setter методы для инкапсуляции ========== // ========== Getter/Setter методы для инкапсуляции ==========
// Config // Config

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,8 @@
//! - Ctrl+P: View pinned messages //! - Ctrl+P: View pinned messages
//! - Ctrl+F: Search messages in chat //! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods}; use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::ChatId; use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg}; use crate::utils::{with_timeout, with_timeout_msg};
@@ -47,7 +47,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
KeyCode::Char('r') if has_ctrl => { KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов // Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string()); app.status_message = Some("Обновление чатов...".to_string());
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления // Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats(); app.td_client.sync_notification_muted_chats();
app.status_message = None; app.status_message = None;

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,11 +37,9 @@ fn parse_account_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut i = 1; let mut i = 1;
while i < args.len() { while i < args.len() {
if args[i] == "--account" { if args[i] == "--account" && i + 1 < args.len() {
if i + 1 < args.len() {
return Some(args[i + 1].clone()); return Some(args[i + 1].clone());
} }
}
i += 1; i += 1;
} }
None None
@@ -57,7 +55,7 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")) .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
) )
.init(); .init();
@@ -70,18 +68,28 @@ async fn main() -> Result<(), io::Error> {
// Резолвим аккаунт из CLI или default // Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg(); let account_arg = parse_account_arg();
let (account_name, db_path) = let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref()) accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
.unwrap_or_else(|e| {
eprintln!("Error: {}", e); eprintln!("Error: {}", e);
std::process::exit(1); std::process::exit(1);
}); });
// Создаём директорию аккаунта если её нет // Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir( let db_path = accounts::ensure_account_dir(
account_arg.as_deref().unwrap_or(&accounts_config.default_account), account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
) )
.unwrap_or(db_path); .unwrap_or(db_path);
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
let account_lock = accounts::acquire_lock(
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
)
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Отключаем логи TDLib ДО создания клиента // Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs(); disable_tdlib_logs();
@@ -103,6 +111,7 @@ async fn main() -> Result<(), io::Error> {
// Create app state with account-specific db_path // Create app state with account-specific db_path
let mut app = App::new(config, db_path); let mut app = App::new(config, db_path);
app.current_account_name = account_name; app.current_account_name = account_name;
app.account_lock = Some(account_lock);
// Запускаем инициализацию TDLib в фоне (только для реального клиента) // Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id(); let client_id = app.td_client.client_id();
@@ -111,7 +120,7 @@ async fn main() -> Result<(), io::Error> {
let db_path_str = app.td_client.db_path.to_string_lossy().to_string(); let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move { tokio::spawn(async move {
let _ = tdlib_rs::functions::set_tdlib_parameters( if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc false, // use_test_dc
db_path_str, // database_directory db_path_str, // database_directory
"".to_string(), // files_directory "".to_string(), // files_directory
@@ -128,7 +137,10 @@ async fn main() -> Result<(), io::Error> {
env!("CARGO_PKG_VERSION").to_string(), // application_version env!("CARGO_PKG_VERSION").to_string(), // application_version
client_id, client_id,
) )
.await; .await
{
tracing::error!("set_tdlib_parameters failed: {:?}", e);
}
}); });
let res = run_app(&mut terminal, &mut app).await; let res = run_app(&mut terminal, &mut app).await;
@@ -160,7 +172,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move { let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) { while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, _client_id))) = result { if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() { if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим break; // Канал закрыт, выходим
@@ -247,7 +259,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Проверяем завершение воспроизведения // Проверяем завершение воспроизведения
if playback.position >= playback.duration if playback.position >= playback.duration
|| app.audio_player.as_ref().map_or(false, |p| p.is_stopped()) || app.audio_player.as_ref().is_some_and(|p| p.is_stopped())
{ {
stop_playback = true; stop_playback = true;
} }
@@ -292,7 +304,11 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await; let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом) // Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(()); return Ok(());
} }
@@ -330,10 +346,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Process pending chat initialization (reply info, pinned, photos) // Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() { if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки) // Загружаем недостающие reply info (игнорируем ошибки)
with_timeout_ignore( with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
Duration::from_secs(5),
app.td_client.fetch_missing_reply_info(),
)
.await; .await;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки) // Загружаем последнее закреплённое сообщение (игнорируем ошибки)
@@ -372,9 +385,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
for file_id in photo_file_ids { for file_id in photo_file_ids {
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::time::timeout( let result = tokio::time::timeout(Duration::from_secs(5), async {
Duration::from_secs(5),
async {
match tdlib_rs::functions::download_file( match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id, file_id, 1, 0, 0, true, client_id,
) )
@@ -389,8 +400,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
Ok(_) => Err("Файл не скачан".to_string()), Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)), Err(e) => Err(format!("{:?}", e)),
} }
}, })
)
.await; .await;
let result = match result { let result = match result {
@@ -409,6 +419,21 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Check pending account switch // Check pending account switch
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { if let Some((account_name, new_db_path)) = app.pending_account_switch.take() {
// 0. Acquire lock for new account before switching
match accounts::acquire_lock(&account_name) {
Ok(new_lock) => {
// Release old lock
if let Some(old_lock) = app.account_lock.take() {
accounts::release_lock(old_lock);
}
app.account_lock = Some(new_lock);
}
Err(e) => {
app.error_message = Some(e);
continue;
}
}
// 1. Stop playback // 1. Stop playback
app.stop_playback(); app.stop_playback();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats, //! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing chats. //! including finding, updating, and adding/removing chats.
use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS}; use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType}; use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
@@ -33,7 +33,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
// Пропускаем удалённые аккаунты // Пропускаем удалённые аккаунты
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() { if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
// Удаляем из списка если уже был добавлен // Удаляем из списка если уже был добавлен
client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id)); client
.chats_mut()
.retain(|c| c.id != ChatId::new(td_chat.id));
return; return;
} }
@@ -70,7 +72,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
let user_id = UserId::new(private.user_id); let user_id = UserId::new(private.user_id);
client.user_cache.chat_user_ids.insert(chat_id, user_id); client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU) // Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client.user_cache.user_usernames client
.user_cache
.user_usernames
.peek(&user_id) .peek(&user_id)
.map(|u| format!("@{}", u)) .map(|u| format!("@{}", u))
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
//! Chat list panel: search box, chat items, and user online status. //! Chat list panel: search box, chat items, and user online status.
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods}; use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus; use crate::tdlib::UserOnlineStatus;
use crate::ui::components; use crate::ui::components;
@@ -76,7 +76,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
app.selected_chat_id app.selected_chat_id
} else { } else {
let filtered = app.get_filtered_chats(); let filtered = app.get_filtered_chats();
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id)) app.chat_list_state
.selected()
.and_then(|i| filtered.get(i).map(|c| c.id))
}; };
let (status_text, status_color) = match status_chat_id { let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)), Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
//! Reusable UI components: message bubbles, input fields, modals, lists. //! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal; pub mod chat_list_item;
pub mod emoji_picker;
pub mod input_field; pub mod input_field;
pub mod message_bubble; pub mod message_bubble;
pub mod message_list; pub mod message_list;
pub mod chat_list_item; pub mod modal;
pub mod emoji_picker;
// Экспорт основных функций // Экспорт основных функций
pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item; pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker; pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; pub use input_field::render_input_field;
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble}; pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar}; pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item};

View File

@@ -74,10 +74,7 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
), ),
Span::raw("Да"), Span::raw("Да"),
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Нет"), Span::raw("Нет"),
]), ]),
]; ];

View File

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

View File

@@ -1,7 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::app::InputMode; use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState; use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
@@ -31,7 +31,10 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if let Some(err) = &app.error_message { } else if let Some(err) = &app.error_message {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err) format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching { } else if app.is_searching {
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator) format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
} else if app.selected_chat_id.is_some() { } else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode { let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close", InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,11 +17,7 @@ fn test_open_account_switcher() {
assert!(app.account_switcher.is_some()); assert!(app.account_switcher.is_some());
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { Some(AccountSwitcherState::SelectAccount { accounts, selected_index, current_account }) => {
accounts,
selected_index,
current_account,
}) => {
assert!(!accounts.is_empty()); assert!(!accounts.is_empty());
assert_eq!(*selected_index, 0); assert_eq!(*selected_index, 0);
assert_eq!(current_account, "default"); assert_eq!(current_account, "default");
@@ -58,11 +54,7 @@ fn test_account_switcher_navigate_down() {
} }
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { Some(AccountSwitcherState::SelectAccount { selected_index, accounts, .. }) => {
selected_index,
accounts,
..
}) => {
// Should be at the "Add account" item (index == accounts.len()) // Should be at the "Add account" item (index == accounts.len())
assert_eq!(*selected_index, accounts.len()); assert_eq!(*selected_index, accounts.len());
} }
@@ -137,11 +129,7 @@ fn test_confirm_add_account_transitions_to_add_state() {
app.account_switcher_confirm(); app.account_switcher_confirm();
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::AddAccount { Some(AccountSwitcherState::AddAccount { name_input, cursor_position, error }) => {
name_input,
cursor_position,
error,
}) => {
assert!(name_input.is_empty()); assert!(name_input.is_empty());
assert_eq!(*cursor_position, 0); assert_eq!(*cursor_position, 0);
assert!(error.is_none()); assert!(error.is_none());

View File

@@ -1,8 +1,6 @@
// Integration tests for accounts module // Integration tests for accounts module
use tele_tui::accounts::{ use tele_tui::accounts::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
account_db_path, validate_account_name, AccountProfile, AccountsConfig,
};
#[test] #[test]
fn test_default_single_config() { fn test_default_single_config() {

View File

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

View File

@@ -1,6 +1,9 @@
// Integration tests for config flow // Integration tests for config flow
use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig}; use tele_tui::config::{
AudioConfig, ColorsConfig, Config, GeneralConfig, ImagesConfig, Keybindings,
NotificationsConfig,
};
/// Test: Дефолтные значения конфигурации /// Test: Дефолтные значения конфигурации
#[test] #[test]
@@ -22,9 +25,7 @@ fn test_config_default_values() {
#[test] #[test]
fn test_config_custom_values() { fn test_config_custom_values() {
let config = Config { let config = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+05:00".to_string() },
timezone: "+05:00".to_string(),
},
colors: ColorsConfig { colors: ColorsConfig {
incoming_message: "cyan".to_string(), incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(), outgoing_message: "blue".to_string(),
@@ -108,9 +109,7 @@ fn test_parse_color_case_insensitive() {
#[test] #[test]
fn test_config_toml_serialization() { fn test_config_toml_serialization() {
let original_config = Config { let original_config = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "-05:00".to_string() },
timezone: "-05:00".to_string(),
},
colors: ColorsConfig { colors: ColorsConfig {
incoming_message: "cyan".to_string(), incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(), outgoing_message: "blue".to_string(),
@@ -164,25 +163,19 @@ mod timezone_tests {
#[test] #[test]
fn test_timezone_formats() { fn test_timezone_formats() {
let positive = Config { let positive = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+03:00".to_string() },
timezone: "+03:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(positive.general.timezone, "+03:00"); assert_eq!(positive.general.timezone, "+03:00");
let negative = Config { let negative = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "-05:00".to_string() },
timezone: "-05:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(negative.general.timezone, "-05:00"); assert_eq!(negative.general.timezone, "-05:00");
let zero = Config { let zero = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+00:00".to_string() },
timezone: "+00:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(zero.general.timezone, "+00:00"); assert_eq!(zero.general.timezone, "+00:00");

View File

@@ -12,13 +12,19 @@ async fn test_delete_message_removes_from_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Delete me".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Delete me".to_string(), None, None)
.await
.unwrap();
// Проверяем что сообщение есть // Проверяем что сообщение есть
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
// Удаляем сообщение // Удаляем сообщение
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
// Проверяем что удаление записалось // Проверяем что удаление записалось
assert_eq!(client.get_deleted_messages().len(), 1); assert_eq!(client.get_deleted_messages().len(), 1);
@@ -34,15 +40,30 @@ async fn test_delete_multiple_messages() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем 3 сообщения // Отправляем 3 сообщения
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); let msg1 = client
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); .send_message(ChatId::new(123), "Message 1".to_string(), None, None)
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); .await
.unwrap();
let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
assert_eq!(client.get_messages(123).len(), 3); assert_eq!(client.get_messages(123).len(), 3);
// Удаляем первое и третье // Удаляем первое и третье
client.delete_messages(ChatId::new(123), vec![msg1.id()], false).await.unwrap(); client
client.delete_messages(ChatId::new(123), vec![msg3.id()], false).await.unwrap(); .delete_messages(ChatId::new(123), vec![msg1.id()], false)
.await
.unwrap();
client
.delete_messages(ChatId::new(123), vec![msg3.id()], false)
.await
.unwrap();
// Проверяем историю удалений // Проверяем историю удалений
assert_eq!(client.get_deleted_messages().len(), 2); assert_eq!(client.get_deleted_messages().len(), 2);
@@ -89,12 +110,18 @@ async fn test_delete_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем одно сообщение // Отправляем одно сообщение
let msg = client.send_message(ChatId::new(123), "Exists".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Exists".to_string(), None, None)
.await
.unwrap();
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
// Пытаемся удалить несуществующее // Пытаемся удалить несуществующее
client.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false)
.await
.unwrap();
// Удаление записалось в историю // Удаление записалось в историю
assert_eq!(client.get_deleted_messages().len(), 1); assert_eq!(client.get_deleted_messages().len(), 1);
@@ -112,7 +139,10 @@ async fn test_delete_nonexistent_message() {
async fn test_delete_with_confirmation_flow() { async fn test_delete_with_confirmation_flow() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "To delete".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "To delete".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App) // Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App)
// В FakeTdClient просто проверяем что сообщение ещё есть // В FakeTdClient просто проверяем что сообщение ещё есть
@@ -120,7 +150,10 @@ async fn test_delete_with_confirmation_flow() {
assert_eq!(client.get_deleted_messages().len(), 0); assert_eq!(client.get_deleted_messages().len(), 0);
// Шаг 2: Пользователь подтвердил 'y' -> удаляем // Шаг 2: Пользователь подтвердил 'y' -> удаляем
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
// Проверяем что удалено // Проверяем что удалено
assert_eq!(client.get_messages(123).len(), 0); assert_eq!(client.get_messages(123).len(), 0);
@@ -132,7 +165,10 @@ async fn test_delete_with_confirmation_flow() {
async fn test_cancel_delete_keeps_message() { async fn test_cancel_delete_keeps_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Keep me".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Keep me".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показалась модалка // Шаг 1: Пользователь нажал 'd' -> показалась модалка
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);

View File

@@ -3,8 +3,8 @@
mod helpers; mod helpers;
use helpers::test_data::{create_test_chat, TestChatBuilder}; use helpers::test_data::{create_test_chat, TestChatBuilder};
use tele_tui::types::{ChatId, MessageId};
use std::collections::HashMap; use std::collections::HashMap;
use tele_tui::types::{ChatId, MessageId};
/// Простая структура для хранения черновиков (как в реальном App) /// Простая структура для хранения черновиков (как в реальном App)
struct DraftManager { struct DraftManager {

View File

@@ -23,10 +23,7 @@ async fn test_user_journey_app_launch_to_chat_list() {
let chat2 = TestChatBuilder::new("Work Group", 102).build(); let chat2 = TestChatBuilder::new("Work Group", 102).build();
let chat3 = TestChatBuilder::new("Boss", 103).build(); let chat3 = TestChatBuilder::new("Boss", 103).build();
let client = client let client = client.with_chat(chat1).with_chat(chat2).with_chat(chat3);
.with_chat(chat1)
.with_chat(chat2)
.with_chat(chat3);
// 4. Симулируем загрузку чатов через load_chats // 4. Симулируем загрузку чатов через load_chats
let loaded_chats = client.load_chats(50).await.unwrap(); let loaded_chats = client.load_chats(50).await.unwrap();
@@ -58,9 +55,7 @@ async fn test_user_journey_open_chat_send_message() {
.outgoing() .outgoing()
.build(); .build();
let client = client let client = client.with_message(123, msg1).with_message(123, msg2);
.with_message(123, msg1)
.with_message(123, msg2);
// 3. Открываем чат // 3. Открываем чат
client.open_chat(ChatId::new(123)).await.unwrap(); client.open_chat(ChatId::new(123)).await.unwrap();
@@ -77,12 +72,10 @@ async fn test_user_journey_open_chat_send_message() {
assert_eq!(history[1].text(), "I'm good, thanks!"); assert_eq!(history[1].text(), "I'm good, thanks!");
// 7. Отправляем новое сообщение // 7. Отправляем новое сообщение
let _new_msg = client.send_message( let _new_msg = client
ChatId::new(123), .send_message(ChatId::new(123), "What's for dinner?".to_string(), None, None)
"What's for dinner?".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 8. Проверяем что сообщение отправлено // 8. Проверяем что сообщение отправлено
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -153,34 +146,43 @@ async fn test_user_journey_multi_step_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 4. Входящее сообщение от Alice // 4. Входящее сообщение от Alice
client.simulate_incoming_message(ChatId::new(789), "How's the project going?".to_string(), "Alice"); client.simulate_incoming_message(
ChatId::new(789),
"How's the project going?".to_string(),
"Alice",
);
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
// 5. Отвечаем // 5. Отвечаем
client.send_message( client
.send_message(
ChatId::new(789), ChatId::new(789),
"Almost done! Just need to finish tests.".to_string(), "Almost done! Just need to finish tests.".to_string(),
None, None,
None None,
).await.unwrap(); )
.await
.unwrap();
// 6. Проверяем историю после первого обмена // 6. Проверяем историю после первого обмена
let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
assert_eq!(history1.len(), 2); assert_eq!(history1.len(), 2);
// 7. Еще одно входящее сообщение // 7. Еще одно входящее сообщение
client.simulate_incoming_message(ChatId::new(789), "Great! Let me know if you need help.".to_string(), "Alice"); client.simulate_incoming_message(
ChatId::new(789),
"Great! Let me know if you need help.".to_string(),
"Alice",
);
// 8. Снова отвечаем // 8. Снова отвечаем
client.send_message( client
ChatId::new(789), .send_message(ChatId::new(789), "Will do, thanks!".to_string(), None, None)
"Will do, thanks!".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 9. Финальная проверка истории // 9. Финальная проверка истории
let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
@@ -219,24 +221,20 @@ async fn test_user_journey_switch_chats() {
assert_eq!(client.get_current_chat_id(), Some(111)); assert_eq!(client.get_current_chat_id(), Some(111));
// 3. Отправляем сообщение в первом чате // 3. Отправляем сообщение в первом чате
client.send_message( client
ChatId::new(111), .send_message(ChatId::new(111), "Message in chat 1".to_string(), None, None)
"Message in chat 1".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 4. Переключаемся на второй чат // 4. Переключаемся на второй чат
client.open_chat(ChatId::new(222)).await.unwrap(); client.open_chat(ChatId::new(222)).await.unwrap();
assert_eq!(client.get_current_chat_id(), Some(222)); assert_eq!(client.get_current_chat_id(), Some(222));
// 5. Отправляем сообщение во втором чате // 5. Отправляем сообщение во втором чате
client.send_message( client
ChatId::new(222), .send_message(ChatId::new(222), "Message in chat 2".to_string(), None, None)
"Message in chat 2".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 6. Переключаемся на третий чат // 6. Переключаемся на третий чат
client.open_chat(ChatId::new(333)).await.unwrap(); client.open_chat(ChatId::new(333)).await.unwrap();
@@ -270,12 +268,10 @@ async fn test_user_journey_edit_during_conversation() {
client.open_chat(ChatId::new(555)).await.unwrap(); client.open_chat(ChatId::new(555)).await.unwrap();
// 2. Отправляем сообщение с опечаткой // 2. Отправляем сообщение с опечаткой
let msg = client.send_message( let msg = client
ChatId::new(555), .send_message(ChatId::new(555), "I'll be there at 5pm tomorow".to_string(), None, None)
"I'll be there at 5pm tomorow".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 3. Проверяем что сообщение отправлено // 3. Проверяем что сообщение отправлено
let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
@@ -283,17 +279,19 @@ async fn test_user_journey_edit_during_conversation() {
assert_eq!(history[0].text(), "I'll be there at 5pm tomorow"); assert_eq!(history[0].text(), "I'll be there at 5pm tomorow");
// 4. Исправляем опечатку // 4. Исправляем опечатку
client.edit_message( client
ChatId::new(555), .edit_message(ChatId::new(555), msg.id(), "I'll be there at 5pm tomorrow".to_string())
msg.id(), .await
"I'll be there at 5pm tomorrow".to_string() .unwrap();
).await.unwrap();
// 5. Проверяем что сообщение отредактировано // 5. Проверяем что сообщение отредактировано
let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
assert_eq!(edited_history.len(), 1); assert_eq!(edited_history.len(), 1);
assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow"); assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow");
assert!(edited_history[0].metadata.edit_date > 0, "Должна быть установлена дата редактирования"); assert!(
edited_history[0].metadata.edit_date > 0,
"Должна быть установлена дата редактирования"
);
// 6. Проверяем историю редактирований // 6. Проверяем историю редактирований
assert_eq!(client.get_edited_messages().len(), 1); assert_eq!(client.get_edited_messages().len(), 1);
@@ -315,7 +313,11 @@ async fn test_user_journey_reply_in_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 3. Входящее сообщение с вопросом // 3. Входящее сообщение с вопросом
client.simulate_incoming_message(ChatId::new(666), "Can you send me the report?".to_string(), "Charlie"); client.simulate_incoming_message(
ChatId::new(666),
"Can you send me the report?".to_string(),
"Charlie",
);
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
@@ -324,12 +326,10 @@ async fn test_user_journey_reply_in_conversation() {
let question_msg_id = history[0].id(); let question_msg_id = history[0].id();
// 4. Отправляем другое сообщение (не связанное) // 4. Отправляем другое сообщение (не связанное)
client.send_message( client
ChatId::new(666), .send_message(ChatId::new(666), "Working on it now".to_string(), None, None)
"Working on it now".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 5. Отвечаем на конкретный вопрос (reply) // 5. Отвечаем на конкретный вопрос (reply)
let reply_info = Some(tele_tui::tdlib::ReplyInfo { let reply_info = Some(tele_tui::tdlib::ReplyInfo {
@@ -338,12 +338,15 @@ async fn test_user_journey_reply_in_conversation() {
text: "Can you send me the report?".to_string(), text: "Can you send me the report?".to_string(),
}); });
client.send_message( client
.send_message(
ChatId::new(666), ChatId::new(666),
"Sure, sending now!".to_string(), "Sure, sending now!".to_string(),
Some(question_msg_id), Some(question_msg_id),
reply_info reply_info,
).await.unwrap(); )
.await
.unwrap();
// 6. Проверяем что reply сохранён // 6. Проверяем что reply сохранён
let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap(); let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap();
@@ -376,12 +379,10 @@ async fn test_user_journey_network_state_changes() {
// 4. Открываем чат и отправляем сообщение // 4. Открываем чат и отправляем сообщение
client.open_chat(ChatId::new(888)).await.unwrap(); client.open_chat(ChatId::new(888)).await.unwrap();
client.send_message( client
ChatId::new(888), .send_message(ChatId::new(888), "Test message".to_string(), None, None)
"Test message".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// Очищаем канал от update NewMessage // Очищаем канал от update NewMessage
let _ = rx.try_recv(); let _ = rx.try_recv();
@@ -391,8 +392,14 @@ async fn test_user_journey_network_state_changes() {
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })), assert!(
"Expected ConnectionState update, got: {:?}", update); matches!(
update,
Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })
),
"Expected ConnectionState update, got: {:?}",
update
);
// 6. Проверяем что состояние изменилось // 6. Проверяем что состояние изменилось
assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork); assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork);
@@ -405,12 +412,10 @@ async fn test_user_journey_network_state_changes() {
assert_eq!(client.get_network_state(), NetworkState::Ready); assert_eq!(client.get_network_state(), NetworkState::Ready);
// 8. Отправляем сообщение после восстановления // 8. Отправляем сообщение после восстановления
client.send_message( client
ChatId::new(888), .send_message(ChatId::new(888), "Connection restored!".to_string(), None, None)
"Connection restored!".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 9. Проверяем что оба сообщения в истории // 9. Проверяем что оба сообщения в истории
let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap(); let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap();

View File

@@ -12,10 +12,16 @@ async fn test_edit_message_changes_text() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Original text".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original text".to_string(), None, None)
.await
.unwrap();
// Редактируем сообщение // Редактируем сообщение
client.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string())
.await
.unwrap();
// Проверяем что редактирование записалось // Проверяем что редактирование записалось
assert_eq!(client.get_edited_messages().len(), 1); assert_eq!(client.get_edited_messages().len(), 1);
@@ -34,7 +40,10 @@ async fn test_edit_message_sets_edit_date() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Получаем дату до редактирования // Получаем дату до редактирования
let messages_before = client.get_messages(123); let messages_before = client.get_messages(123);
@@ -42,7 +51,10 @@ async fn test_edit_message_sets_edit_date() {
assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось
// Редактируем сообщение // Редактируем сообщение
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
// Проверяем что edit_date установлена // Проверяем что edit_date установлена
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
@@ -78,16 +90,28 @@ async fn test_can_only_edit_own_messages() {
async fn test_multiple_edits_of_same_message() { async fn test_multiple_edits_of_same_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Version 1".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Version 1".to_string(), None, None)
.await
.unwrap();
// Первое редактирование // Первое редактирование
client.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string())
.await
.unwrap();
// Второе редактирование // Второе редактирование
client.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string())
.await
.unwrap();
// Третье редактирование // Третье редактирование
client.edit_message(ChatId::new(123), msg.id(), "Final version".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Final version".to_string())
.await
.unwrap();
// Проверяем что все 3 редактирования записаны // Проверяем что все 3 редактирования записаны
assert_eq!(client.get_edited_messages().len(), 3); assert_eq!(client.get_edited_messages().len(), 3);
@@ -107,7 +131,9 @@ async fn test_edit_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Пытаемся отредактировать несуществующее сообщение // Пытаемся отредактировать несуществующее сообщение
let result = client.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()).await; let result = client
.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string())
.await;
// Должна вернуться ошибка // Должна вернуться ошибка
assert!(result.is_err()); assert!(result.is_err());
@@ -124,7 +150,10 @@ async fn test_edit_nonexistent_message() {
async fn test_edit_history_tracking() { async fn test_edit_history_tracking() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Симулируем начало редактирования -> изменение -> отмена // Симулируем начало редактирования -> изменение -> отмена
// Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён // Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён
@@ -134,14 +163,20 @@ async fn test_edit_history_tracking() {
let original = messages_before[0].text().to_string(); let original = messages_before[0].text().to_string();
// Редактируем // Редактируем
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
// Проверяем что изменилось // Проверяем что изменилось
let messages_edited = client.get_messages(123); let messages_edited = client.get_messages(123);
assert_eq!(messages_edited[0].text(), "Edited"); assert_eq!(messages_edited[0].text(), "Edited");
// Можем "отменить" редактирование вернув original // Можем "отменить" редактирование вернув original
client.edit_message(ChatId::new(123), msg.id(), original).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), original)
.await
.unwrap();
// Проверяем что вернулось // Проверяем что вернулось
let messages_restored = client.get_messages(123); let messages_restored = client.get_messages(123);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,16 @@ async fn test_add_reaction_to_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "React to this!".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "React to this!".to_string(), None, None)
.await
.unwrap();
// Добавляем реакцию // Добавляем реакцию
client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
.await
.unwrap();
// Проверяем что реакция записалась // Проверяем что реакция записалась
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -46,7 +52,10 @@ async fn test_toggle_reaction_removes_it() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Toggle - удаляем свою реакцию // Toggle - удаляем свою реакцию
client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string())
.await
.unwrap();
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
assert_eq!(messages_after[0].reactions().len(), 0); assert_eq!(messages_after[0].reactions().len(), 0);
@@ -57,13 +66,28 @@ async fn test_toggle_reaction_removes_it() {
async fn test_multiple_reactions_on_one_message() { async fn test_multiple_reactions_on_one_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Many reactions".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Many reactions".to_string(), None, None)
.await
.unwrap();
// Добавляем несколько разных реакций // Добавляем несколько разных реакций
client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap(); client
client.toggle_reaction(ChatId::new(123), msg.id(), "❤️".to_string()).await.unwrap(); .toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
client.toggle_reaction(ChatId::new(123), msg.id(), "😂".to_string()).await.unwrap(); .await
client.toggle_reaction(ChatId::new(123), msg.id(), "🔥".to_string()).await.unwrap(); .unwrap();
client
.toggle_reaction(ChatId::new(123), msg.id(), "❤️".to_string())
.await
.unwrap();
client
.toggle_reaction(ChatId::new(123), msg.id(), "😂".to_string())
.await
.unwrap();
client
.toggle_reaction(ChatId::new(123), msg.id(), "🔥".to_string())
.await
.unwrap();
// Проверяем что все 4 реакции записались // Проверяем что все 4 реакции записались
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -151,7 +175,10 @@ async fn test_reaction_counter_increases() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Мы добавляем свою реакцию - счётчик должен увеличиться // Мы добавляем свою реакцию - счётчик должен увеличиться
client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string())
.await
.unwrap();
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[0].reactions()[0].count, 2); assert_eq!(messages[0].reactions()[0].count, 2);
@@ -177,7 +204,10 @@ async fn test_update_reaction_we_add_ours() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Добавляем нашу реакцию // Добавляем нашу реакцию
client.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string())
.await
.unwrap();
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions()[0]; let reaction = &messages[0].reactions()[0];

View File

@@ -4,8 +4,8 @@ mod helpers;
use helpers::fake_tdclient::FakeTdClient; use helpers::fake_tdclient::FakeTdClient;
use helpers::test_data::TestMessageBuilder; use helpers::test_data::TestMessageBuilder;
use tele_tui::tdlib::ReplyInfo;
use tele_tui::tdlib::types::ForwardInfo; use tele_tui::tdlib::types::ForwardInfo;
use tele_tui::tdlib::ReplyInfo;
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Test: Reply создаёт сообщение с reply_to /// Test: Reply создаёт сообщение с reply_to
@@ -28,7 +28,15 @@ async fn test_reply_creates_message_with_reply_to() {
}; };
// Отвечаем на него // Отвечаем на него
let reply_msg = client.send_message(ChatId::new(123), "Answer!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap(); let reply_msg = client
.send_message(
ChatId::new(123),
"Answer!".to_string(),
Some(MessageId::new(100)),
Some(reply_info),
)
.await
.unwrap();
// Проверяем что ответ отправлен с reply_to // Проверяем что ответ отправлен с reply_to
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -79,7 +87,10 @@ async fn test_cancel_reply_sends_without_reply_to() {
// Пользователь начал reply (r), потом отменил (Esc), затем отправил // Пользователь начал reply (r), потом отменил (Esc), затем отправил
// Это эмулируется отправкой без reply_to // Это эмулируется отправкой без reply_to
client.send_message(ChatId::new(123), "Regular message".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "Regular message".to_string(), None, None)
.await
.unwrap();
// Проверяем что отправилось без reply_to // Проверяем что отправилось без reply_to
assert_eq!(client.get_sent_messages()[0].reply_to, None); assert_eq!(client.get_sent_messages()[0].reply_to, None);
@@ -175,7 +186,15 @@ async fn test_reply_to_forwarded_message() {
}; };
// Отвечаем на пересланное сообщение // Отвечаем на пересланное сообщение
let reply_msg = client.send_message(ChatId::new(123), "Thanks for sharing!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap(); let reply_msg = client
.send_message(
ChatId::new(123),
"Thanks for sharing!".to_string(),
Some(MessageId::new(100)),
Some(reply_info),
)
.await
.unwrap();
// Проверяем что reply содержит reply_to // Проверяем что reply содержит reply_to
assert_eq!(client.get_sent_messages()[0].reply_to, Some(MessageId::new(100))); assert_eq!(client.get_sent_messages()[0].reply_to, Some(MessageId::new(100)));

View File

@@ -14,7 +14,10 @@ async fn test_send_text_message() {
let client = client.with_chat(chat); let client = client.with_chat(chat);
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None)
.await
.unwrap();
// Проверяем что сообщение было отправлено // Проверяем что сообщение было отправлено
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -36,13 +39,22 @@ async fn test_send_multiple_messages_updates_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем первое сообщение // Отправляем первое сообщение
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); let msg1 = client
.send_message(ChatId::new(123), "Message 1".to_string(), None, None)
.await
.unwrap();
// Отправляем второе сообщение // Отправляем второе сообщение
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
// Отправляем третье сообщение // Отправляем третье сообщение
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
// Проверяем что все 3 сообщения отслеживаются // Проверяем что все 3 сообщения отслеживаются
assert_eq!(client.get_sent_messages().len(), 3); assert_eq!(client.get_sent_messages().len(), 3);
@@ -66,7 +78,10 @@ async fn test_send_empty_message_technical() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// FakeTdClient технически может отправить пустое сообщение // FakeTdClient технически может отправить пустое сообщение
let msg = client.send_message(ChatId::new(123), "".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "".to_string(), None, None)
.await
.unwrap();
// Проверяем что оно отправилось (в реальном App это должно фильтроваться) // Проверяем что оно отправилось (в реальном App это должно фильтроваться)
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -85,7 +100,10 @@ async fn test_send_message_with_markdown() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let text = "**Bold** *italic* `code`"; let text = "**Bold** *italic* `code`";
client.send_message(ChatId::new(123), text.to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), text.to_string(), None, None)
.await
.unwrap();
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика) // Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -99,13 +117,22 @@ async fn test_send_messages_to_different_chats() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем в чат 123 // Отправляем в чат 123
client.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None)
.await
.unwrap();
// Отправляем в чат 456 // Отправляем в чат 456
client.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None)
.await
.unwrap();
// Отправляем ещё одно в чат 123 // Отправляем ещё одно в чат 123
client.send_message(ChatId::new(123), "How are you?".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "How are you?".to_string(), None, None)
.await
.unwrap();
// Проверяем общее количество отправленных // Проверяем общее количество отправленных
assert_eq!(client.get_sent_messages().len(), 3); assert_eq!(client.get_sent_messages().len(), 3);
@@ -128,7 +155,10 @@ async fn test_receive_incoming_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Добавляем существующее сообщение // Добавляем существующее сообщение
client.send_message(ChatId::new(123), "My outgoing".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "My outgoing".to_string(), None, None)
.await
.unwrap();
// Симулируем входящее сообщение от собеседника // Симулируем входящее сообщение от собеседника
let incoming_msg = TestMessageBuilder::new("Hey there!", 2000) let incoming_msg = TestMessageBuilder::new("Hey there!", 2000)

View File

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