Compare commits
28 Commits
f8aab8232a
...
dfd4184039
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfd4184039 | ||
|
|
25c57c55fb | ||
| 044b859cec | |||
|
|
51e7941668 | ||
|
|
3b7ef41cae | ||
|
|
166fda93a4 | ||
|
|
d4e1ed1376 | ||
|
|
d9eb61dda7 | ||
|
|
c7865b46a7 | ||
|
|
264f183510 | ||
|
|
2442a90e23 | ||
|
|
48d883a746 | ||
| 7ca9ea29ea | |||
| d10dc6599a | |||
| 0cd477f294 | |||
| 8855a07ccd | |||
| 9cc63952f4 | |||
| 0a4ab1b40d | |||
| 20f1c470c4 | |||
| c2ddb0a449 | |||
| 72a8f3e6b1 | |||
| 61dc09fd50 | |||
| 86e2b4c804 | |||
| 6c297758a0 | |||
| 65a73f35de | |||
| 0f379dc240 | |||
| b81eec55d6 | |||
| 652b101571 |
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
@@ -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
26
.woodpecker/check.yml
Normal 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
|
||||||
25
CONTEXT.md
25
CONTEXT.md
@@ -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
11
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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))
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
127
src/accounts/lock.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
self.stop_playback();
|
// Если new_index >= total — остаёмся на текущем
|
||||||
} else {
|
|
||||||
// Дошли до самого нового сообщения - выходим из режима выбора
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
self.stop_playback();
|
|
||||||
}
|
}
|
||||||
|
// Если уже на последнем — ничего не делаем, остаёмся на месте
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -49,12 +48,12 @@ pub enum Command {
|
|||||||
SelectMessage,
|
SelectMessage,
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
ViewImage, // v - просмотр фото
|
ViewImage, // v - просмотр фото
|
||||||
|
|
||||||
// Voice playback
|
// Voice playback
|
||||||
TogglePlayback, // Space - play/pause
|
TogglePlayback, // Space - play/pause
|
||||||
SeekForward, // → - seek +5s
|
SeekForward, // → - seek +5s
|
||||||
SeekBackward, // ← - seek -5s
|
SeekBackward, // ← - seek -5s
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
SubmitMessage,
|
SubmitMessage,
|
||||||
@@ -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(
|
||||||
KeyBinding::new(KeyCode::Up),
|
Command::MoveUp,
|
||||||
KeyBinding::new(KeyCode::Char('k')),
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
KeyBinding::new(KeyCode::Up),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('k')),
|
||||||
bindings.insert(Command::MoveDown, vec![
|
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||||
KeyBinding::new(KeyCode::Down),
|
],
|
||||||
KeyBinding::new(KeyCode::Char('j')),
|
);
|
||||||
KeyBinding::new(KeyCode::Char('о')), // RU
|
bindings.insert(
|
||||||
]);
|
Command::MoveDown,
|
||||||
bindings.insert(Command::MoveLeft, vec![
|
vec![
|
||||||
KeyBinding::new(KeyCode::Left),
|
KeyBinding::new(KeyCode::Down),
|
||||||
KeyBinding::new(KeyCode::Char('h')),
|
KeyBinding::new(KeyCode::Char('j')),
|
||||||
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
KeyBinding::new(KeyCode::Char('о')), // RU
|
||||||
]);
|
],
|
||||||
bindings.insert(Command::MoveRight, vec![
|
);
|
||||||
KeyBinding::new(KeyCode::Right),
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Char('l')),
|
Command::MoveLeft,
|
||||||
KeyBinding::new(KeyCode::Char('д')), // RU
|
vec![
|
||||||
]);
|
KeyBinding::new(KeyCode::Left),
|
||||||
bindings.insert(Command::PageUp, vec![
|
KeyBinding::new(KeyCode::Char('h')),
|
||||||
KeyBinding::new(KeyCode::PageUp),
|
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('u')),
|
],
|
||||||
]);
|
);
|
||||||
bindings.insert(Command::PageDown, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::PageDown),
|
Command::MoveRight,
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('d')),
|
vec![
|
||||||
]);
|
KeyBinding::new(KeyCode::Right),
|
||||||
|
KeyBinding::new(KeyCode::Char('l')),
|
||||||
|
KeyBinding::new(KeyCode::Char('д')), // RU
|
||||||
|
],
|
||||||
|
);
|
||||||
|
bindings.insert(
|
||||||
|
Command::PageUp,
|
||||||
|
vec![
|
||||||
|
KeyBinding::new(KeyCode::PageUp),
|
||||||
|
KeyBinding::with_ctrl(KeyCode::Char('u')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
bindings.insert(
|
||||||
|
Command::PageDown,
|
||||||
|
vec![
|
||||||
|
KeyBinding::new(KeyCode::PageDown),
|
||||||
|
KeyBinding::with_ctrl(KeyCode::Char('d')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Global
|
// Global
|
||||||
bindings.insert(Command::Quit, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Char('q')),
|
Command::Quit,
|
||||||
KeyBinding::new(KeyCode::Char('й')), // RU
|
vec![
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('c')),
|
KeyBinding::new(KeyCode::Char('q')),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('й')), // RU
|
||||||
bindings.insert(Command::OpenSearch, vec![
|
KeyBinding::with_ctrl(KeyCode::Char('c')),
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('s')),
|
],
|
||||||
]);
|
);
|
||||||
bindings.insert(Command::OpenSearchInChat, vec![
|
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('f')),
|
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
|
||||||
]);
|
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
|
||||||
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(
|
||||||
KeyBinding::new(KeyCode::Delete),
|
Command::DeleteMessage,
|
||||||
KeyBinding::new(KeyCode::Char('d')),
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('в')), // RU
|
KeyBinding::new(KeyCode::Delete),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('d')),
|
||||||
bindings.insert(Command::ReplyMessage, vec![
|
KeyBinding::new(KeyCode::Char('в')), // RU
|
||||||
KeyBinding::new(KeyCode::Char('r')),
|
],
|
||||||
KeyBinding::new(KeyCode::Char('к')), // RU
|
);
|
||||||
]);
|
bindings.insert(
|
||||||
bindings.insert(Command::ForwardMessage, vec![
|
Command::ReplyMessage,
|
||||||
KeyBinding::new(KeyCode::Char('f')),
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('а')), // RU
|
KeyBinding::new(KeyCode::Char('r')),
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('к')), // RU
|
||||||
bindings.insert(Command::CopyMessage, vec![
|
],
|
||||||
KeyBinding::new(KeyCode::Char('y')),
|
);
|
||||||
KeyBinding::new(KeyCode::Char('н')), // RU
|
bindings.insert(
|
||||||
]);
|
Command::ForwardMessage,
|
||||||
bindings.insert(Command::ReactMessage, vec![
|
vec![
|
||||||
KeyBinding::new(KeyCode::Char('e')),
|
KeyBinding::new(KeyCode::Char('f')),
|
||||||
KeyBinding::new(KeyCode::Char('у')), // RU
|
KeyBinding::new(KeyCode::Char('а')), // RU
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
|
bindings.insert(
|
||||||
|
Command::CopyMessage,
|
||||||
|
vec![
|
||||||
|
KeyBinding::new(KeyCode::Char('y')),
|
||||||
|
KeyBinding::new(KeyCode::Char('н')), // RU
|
||||||
|
],
|
||||||
|
);
|
||||||
|
bindings.insert(
|
||||||
|
Command::ReactMessage,
|
||||||
|
vec![
|
||||||
|
KeyBinding::new(KeyCode::Char('e')),
|
||||||
|
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(
|
||||||
KeyBinding::new(KeyCode::Char('v')),
|
Command::ViewImage,
|
||||||
KeyBinding::new(KeyCode::Char('м')), // RU
|
vec![
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('v')),
|
||||||
|
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![
|
);
|
||||||
KeyBinding::new(KeyCode::Home),
|
bindings.insert(
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
Command::MoveToStart,
|
||||||
]);
|
vec![
|
||||||
bindings.insert(Command::MoveToEnd, vec![
|
KeyBinding::new(KeyCode::Home),
|
||||||
KeyBinding::new(KeyCode::End),
|
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
],
|
||||||
]);
|
);
|
||||||
|
bindings.insert(
|
||||||
|
Command::MoveToEnd,
|
||||||
|
vec![
|
||||||
|
KeyBinding::new(KeyCode::End),
|
||||||
|
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Vim mode
|
// Vim mode
|
||||||
bindings.insert(Command::EnterInsertMode, vec![
|
bindings.insert(
|
||||||
KeyBinding::new(KeyCode::Char('i')),
|
Command::EnterInsertMode,
|
||||||
KeyBinding::new(KeyCode::Char('ш')), // RU
|
vec![
|
||||||
]);
|
KeyBinding::new(KeyCode::Char('i')),
|
||||||
|
KeyBinding::new(KeyCode::Char('ш')), // RU
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
bindings.insert(Command::OpenProfile, vec![
|
bindings.insert(
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
Command::OpenProfile,
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
|
vec![
|
||||||
]);
|
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/// Размер кэша изображений по умолчанию (в МБ)
|
/// Размер кэша изображений по умолчанию (в МБ)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,22 +6,22 @@
|
|||||||
//! - 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};
|
||||||
|
|
||||||
/// Обработка режима выбора сообщения для действий
|
/// Обработка режима выбора сообщения для действий
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по сообщениям (Up/Down)
|
/// - Навигацию по сообщениям (Up/Down)
|
||||||
/// - Удаление сообщения (d/в/Delete)
|
/// - Удаление сообщения (d/в/Delete)
|
||||||
@@ -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,13 +201,13 @@ 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();
|
||||||
app.cursor_position = 0;
|
app.cursor_position = 0;
|
||||||
@@ -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
|
||||||
@@ -228,7 +243,7 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка клавиши Enter
|
/// Обработка клавиши Enter
|
||||||
///
|
///
|
||||||
/// Обрабатывает три сценария:
|
/// Обрабатывает три сценария:
|
||||||
/// 1. В режиме выбора сообщения: начать редактирование
|
/// 1. В режиме выбора сообщения: начать редактирование
|
||||||
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
||||||
@@ -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 {
|
||||||
@@ -368,7 +385,7 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка ввода клавиатуры в открытом чате
|
/// Обработка ввода клавиатуры в открытом чате
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Backspace/Delete: удаление символов относительно курсора
|
/// - Backspace/Delete: удаление символов относительно курсора
|
||||||
/// - Char: вставка символов в позицию курсора + typing status
|
/// - Char: вставка символов в позицию курсора + typing status
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -15,11 +17,15 @@ use crossterm::event::KeyEvent;
|
|||||||
use std::time::Duration;
|
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,11 +71,9 @@ 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();
|
||||||
@@ -132,4 +137,4 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
|
|||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -17,12 +17,16 @@ use crossterm::event::KeyEvent;
|
|||||||
use std::time::Duration;
|
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;
|
||||||
@@ -81,4 +82,4 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
app.error_message = Some(e);
|
app.error_message = Some(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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,58 +65,60 @@ 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();
|
}
|
||||||
}
|
KeyCode::Enter => {
|
||||||
KeyCode::Enter => {
|
app.account_switcher_confirm_add();
|
||||||
app.account_switcher_confirm_add();
|
}
|
||||||
}
|
KeyCode::Backspace => {
|
||||||
KeyCode::Backspace => {
|
if let Some(AccountSwitcherState::AddAccount {
|
||||||
if let Some(AccountSwitcherState::AddAccount {
|
name_input,
|
||||||
name_input,
|
cursor_position,
|
||||||
cursor_position,
|
error,
|
||||||
error,
|
}) = &mut app.account_switcher
|
||||||
}) = &mut app.account_switcher
|
{
|
||||||
{
|
if *cursor_position > 0 {
|
||||||
if *cursor_position > 0 {
|
|
||||||
let mut chars: Vec<char> = name_input.chars().collect();
|
|
||||||
chars.remove(*cursor_position - 1);
|
|
||||||
*name_input = chars.into_iter().collect();
|
|
||||||
*cursor_position -= 1;
|
|
||||||
*error = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
if let Some(AccountSwitcherState::AddAccount {
|
|
||||||
name_input,
|
|
||||||
cursor_position,
|
|
||||||
error,
|
|
||||||
}) = &mut app.account_switcher
|
|
||||||
{
|
|
||||||
let mut chars: Vec<char> = name_input.chars().collect();
|
let mut chars: Vec<char> = name_input.chars().collect();
|
||||||
chars.insert(*cursor_position, c);
|
chars.remove(*cursor_position - 1);
|
||||||
*name_input = chars.into_iter().collect();
|
*name_input = chars.into_iter().collect();
|
||||||
*cursor_position += 1;
|
*cursor_position -= 1;
|
||||||
*error = None;
|
*error = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Char(c) => {
|
||||||
|
if let Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
}) = &mut app.account_switcher
|
||||||
|
{
|
||||||
|
let mut chars: Vec<char> = name_input.chars().collect();
|
||||||
|
chars.insert(*cursor_position, c);
|
||||||
|
*name_input = chars.into_iter().collect();
|
||||||
|
*cursor_position += 1;
|
||||||
|
*error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка режима профиля пользователя/чата
|
/// Обработка режима профиля пользователя/чата
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Модалку подтверждения выхода из группы (двухшаговая)
|
/// - Модалку подтверждения выхода из группы (двухшаговая)
|
||||||
/// - Навигацию по действиям профиля (Up/Down)
|
/// - Навигацию по действиям профиля (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;
|
||||||
@@ -233,7 +232,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
||||||
///
|
///
|
||||||
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
||||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
let Some(chat_id) = app.selected_chat_id else {
|
let Some(chat_id) = app.selected_chat_id else {
|
||||||
@@ -319,12 +318,16 @@ pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка режима выбора реакции (emoji picker)
|
/// Обработка режима выбора реакции (emoji picker)
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по сетке реакций: 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);
|
||||||
@@ -372,12 +373,16 @@ 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();
|
||||||
@@ -396,4 +401,4 @@ pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEve
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -17,13 +17,17 @@ use super::chat_list::open_chat_and_load_data;
|
|||||||
use super::scroll_to_message;
|
use super::scroll_to_message;
|
||||||
|
|
||||||
/// Обработка режима поиска по чатам
|
/// Обработка режима поиска по чатам
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Редактирование поискового запроса (Backspace, Char)
|
/// - Редактирование поискового запроса (Backspace, Char)
|
||||||
/// - Навигацию по отфильтрованному списку (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,30 +44,32 @@ 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));
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
app.search_query.push(c);
|
|
||||||
app.chat_list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Char(c) => {
|
||||||
|
app.search_query.push(c);
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка режима поиска по сообщениям в открытом чате
|
/// Обработка режима поиска по сообщениям в открытом чате
|
||||||
///
|
///
|
||||||
/// Обрабатывает:
|
/// Обрабатывает:
|
||||||
/// - Навигацию по результатам поиска (Up/Down/N/n)
|
/// - Навигацию по результатам поиска (Up/Down/N/n)
|
||||||
/// - Переход к выбранному сообщению (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,33 +86,31 @@ 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();
|
|
||||||
}
|
|
||||||
KeyCode::Char('n') => {
|
|
||||||
app.select_next_search_result();
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
query.pop();
|
|
||||||
app.update_search_query(query.clone());
|
|
||||||
perform_message_search(app, &query).await;
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
query.push(c);
|
|
||||||
app.update_search_query(query.clone());
|
|
||||||
perform_message_search(app, &query).await;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Char('n') => {
|
||||||
|
app.select_next_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
query.pop();
|
||||||
|
app.update_search_query(query.clone());
|
||||||
|
perform_message_search(app, &query).await;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
query.push(c);
|
||||||
|
app.update_search_query(query.clone());
|
||||||
|
perform_message_search(app, &query).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,4 +133,4 @@ pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &
|
|||||||
{
|
{
|
||||||
app.set_search_results(results);
|
app.set_search_results(results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -252,7 +246,7 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка модального окна просмотра изображения
|
/// Обработка модального окна просмотра изображения
|
||||||
///
|
///
|
||||||
/// Hotkeys:
|
/// Hotkeys:
|
||||||
/// - Esc/q: закрыть модальное окно
|
/// - Esc/q: закрыть модальное окно
|
||||||
/// - ←: предыдущее фото в чате
|
/// - ←: предыдущее фото в чате
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
src/main.rs
117
src/main.rs
@@ -37,10 +37,8 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -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,15 +120,15 @@ 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
|
||||||
"".to_string(), // database_encryption_key
|
"".to_string(), // database_encryption_key
|
||||||
true, // use_file_database
|
true, // use_file_database
|
||||||
true, // use_chat_info_database
|
true, // use_chat_info_database
|
||||||
true, // use_message_database
|
true, // use_message_database
|
||||||
false, // use_secret_chats
|
false, // use_secret_chats
|
||||||
api_id,
|
api_id,
|
||||||
api_hash,
|
api_hash,
|
||||||
"en".to_string(), // system_language_code
|
"en".to_string(), // system_language_code
|
||||||
@@ -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,11 +346,8 @@ 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),
|
.await;
|
||||||
app.td_client.fetch_missing_reply_info(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
||||||
with_timeout_ignore(
|
with_timeout_ignore(
|
||||||
@@ -372,25 +385,22 @@ 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),
|
match tdlib_rs::functions::download_file(
|
||||||
async {
|
file_id, 1, 0, 0, true, client_id,
|
||||||
match tdlib_rs::functions::download_file(
|
)
|
||||||
file_id, 1, 0, 0, true, client_id,
|
.await
|
||||||
)
|
{
|
||||||
.await
|
Ok(tdlib_rs::enums::File::File(file))
|
||||||
|
if file.local.is_downloading_completed
|
||||||
|
&& !file.local.path.is_empty() =>
|
||||||
{
|
{
|
||||||
Ok(tdlib_rs::enums::File::File(file))
|
Ok(file.local.path)
|
||||||
if file.local.is_downloading_completed
|
|
||||||
&& !file.local.path.is_empty() =>
|
|
||||||
{
|
|
||||||
Ok(file.local.path)
|
|
||||||
}
|
|
||||||
Ok(_) => Err("Файл не скачан".to_string()),
|
|
||||||
Err(e) => Err(format!("{:?}", e)),
|
|
||||||
}
|
}
|
||||||
},
|
Ok(_) => Err("Файл не скачан".to_string()),
|
||||||
)
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ impl ImageRenderer {
|
|||||||
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
|
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
|
||||||
pub fn new() -> Option<Self> {
|
pub fn new() -> Option<Self> {
|
||||||
let picker = Picker::from_query_stdio().ok()?;
|
let picker = Picker::from_query_stdio().ok()?;
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
picker,
|
picker,
|
||||||
protocols: HashMap::new(),
|
protocols: HashMap::new(),
|
||||||
@@ -41,7 +41,7 @@ impl ImageRenderer {
|
|||||||
pub fn new_fast() -> Option<Self> {
|
pub fn new_fast() -> Option<Self> {
|
||||||
let mut picker = Picker::from_fontsize((8, 12));
|
let mut picker = Picker::from_fontsize((8, 12));
|
||||||
picker.set_protocol_type(ProtocolType::Halfblocks);
|
picker.set_protocol_type(ProtocolType::Halfblocks);
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
picker,
|
picker,
|
||||||
protocols: HashMap::new(),
|
protocols: HashMap::new(),
|
||||||
@@ -51,7 +51,7 @@ impl ImageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Загружает изображение из файла и создаёт протокол рендеринга.
|
/// Загружает изображение из файла и создаёт протокол рендеринга.
|
||||||
///
|
///
|
||||||
/// Если протокол уже существует, не загружает повторно (кэширование).
|
/// Если протокол уже существует, не загружает повторно (кэширование).
|
||||||
/// Использует LRU eviction при превышении лимита.
|
/// Использует LRU eviction при превышении лимита.
|
||||||
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
|
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
|
||||||
@@ -76,7 +76,7 @@ impl ImageRenderer {
|
|||||||
|
|
||||||
let protocol = self.picker.new_resize_protocol(img);
|
let protocol = self.picker.new_resize_protocol(img);
|
||||||
self.protocols.insert(msg_id_i64, protocol);
|
self.protocols.insert(msg_id_i64, protocol);
|
||||||
|
|
||||||
// Обновляем access order
|
// Обновляем access order
|
||||||
self.access_counter += 1;
|
self.access_counter += 1;
|
||||||
self.access_order.insert(msg_id_i64, self.access_counter);
|
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||||
@@ -93,21 +93,22 @@ impl ImageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Получает мутабельную ссылку на протокол для рендеринга.
|
/// Получает мутабельную ссылку на протокол для рендеринга.
|
||||||
///
|
///
|
||||||
/// Обновляет access time для LRU.
|
/// Обновляет access time для LRU.
|
||||||
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
|
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
|
||||||
let msg_id_i64 = msg_id.as_i64();
|
let msg_id_i64 = msg_id.as_i64();
|
||||||
|
|
||||||
if self.protocols.contains_key(&msg_id_i64) {
|
if self.protocols.contains_key(&msg_id_i64) {
|
||||||
// Обновляем access time
|
// Обновляем access time
|
||||||
self.access_counter += 1;
|
self.access_counter += 1;
|
||||||
self.access_order.insert(msg_id_i64, self.access_counter);
|
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.protocols.get_mut(&msg_id_i64)
|
self.protocols.get_mut(&msg_id_i64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удаляет протокол для сообщения
|
/// Удаляет протокол для сообщения
|
||||||
|
#[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();
|
||||||
|
|||||||
@@ -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 оставшийся аккумулятор
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет, завершена ли авторизация.
|
/// Проверяет, завершена ли авторизация.
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +205,15 @@ 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)
|
||||||
full_info.bio.map(|b| b.text)
|
.await
|
||||||
} else {
|
{
|
||||||
None
|
full_info.bio.map(|b| b.text)
|
||||||
};
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let online_status_str = match user.status {
|
let online_status_str = match user.status {
|
||||||
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
|
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,16 +73,15 @@ 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())
|
.parse()
|
||||||
.parse()
|
.unwrap_or(0);
|
||||||
.unwrap_or(0);
|
let api_hash = env::var("API_HASH").unwrap_or_default();
|
||||||
let api_hash = env::var("API_HASH").unwrap_or_default();
|
(api_id, api_hash)
|
||||||
(api_id, api_hash)
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let client_id = tdlib_rs::create_client();
|
let client_id = tdlib_rs::create_client();
|
||||||
|
|
||||||
@@ -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(
|
||||||
chat.unread_count = update.unread_count;
|
self,
|
||||||
});
|
ChatId::new(update.chat_id),
|
||||||
|
|chat| {
|
||||||
|
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(
|
||||||
chat.unread_mention_count = update.unread_mention_count;
|
self,
|
||||||
});
|
ChatId::new(update.chat_id),
|
||||||
|
|chat| {
|
||||||
|
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(
|
||||||
// mute_for > 0 означает что чат замьючен
|
self,
|
||||||
chat.is_muted = update.notification_settings.mute_for > 0;
|
ChatId::new(update.chat_id),
|
||||||
});
|
|chat| {
|
||||||
|
// 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(
|
||||||
chat.last_read_outbox_message_id = last_read_msg_id;
|
self,
|
||||||
});
|
ChatId::new(update.chat_id),
|
||||||
|
|chat| {
|
||||||
|
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
|
||||||
|
|||||||
@@ -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 ============
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 для сообщений с неполными данными
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 +206,9 @@ 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)
|
||||||
@@ -233,17 +236,20 @@ 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(),
|
||||||
None,
|
None,
|
||||||
0, // from_message_id
|
0, // from_message_id
|
||||||
0, // offset
|
0, // offset
|
||||||
100, // limit
|
100, // limit
|
||||||
Some(SearchMessagesFilter::Pinned),
|
Some(SearchMessagesFilter::Pinned),
|
||||||
0, // message_thread_id
|
0, // message_thread_id
|
||||||
0, // saved_messages_topic_id
|
0, // saved_messages_topic_id
|
||||||
self.client_id,
|
self.client_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -310,8 +316,8 @@ impl MessageManager {
|
|||||||
0, // offset
|
0, // offset
|
||||||
100, // limit
|
100, // limit
|
||||||
None,
|
None,
|
||||||
0, // message_thread_id
|
0, // message_thread_id
|
||||||
0, // saved_messages_topic_id
|
0, // saved_messages_topic_id
|
||||||
self.client_id,
|
self.client_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -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 {
|
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||||
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 {
|
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -314,13 +308,13 @@ impl MessageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Builder для удобного создания MessageInfo с fluent API
|
/// Builder для удобного создания MessageInfo с fluent API
|
||||||
///
|
///
|
||||||
/// # Примеры
|
/// # Примеры
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use tele_tui::tdlib::MessageBuilder;
|
/// use tele_tui::tdlib::MessageBuilder;
|
||||||
/// use tele_tui::types::MessageId;
|
/// use tele_tui::types::MessageId;
|
||||||
///
|
///
|
||||||
/// let message = MessageBuilder::new(MessageId::new(123))
|
/// let message = MessageBuilder::new(MessageId::new(123))
|
||||||
/// .sender_name("Alice")
|
/// .sender_name("Alice")
|
||||||
/// .text("Hello, world!")
|
/// .text("Hello, world!")
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ mod tests {
|
|||||||
// let chat_id = ChatId::new(1);
|
// let chat_id = ChatId::new(1);
|
||||||
// let message_id = MessageId::new(1);
|
// let message_id = MessageId::new(1);
|
||||||
// if chat_id == message_id { } // ERROR: mismatched types
|
// if chat_id == message_id { } // ERROR: mismatched types
|
||||||
|
|
||||||
// Runtime values can be the same, but types are different
|
// Runtime values can be the same, but types are different
|
||||||
let chat_id = ChatId::new(1);
|
let chat_id = ChatId::new(1);
|
||||||
let message_id = MessageId::new(1);
|
let message_id = MessageId::new(1);
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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("Отмена"),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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 if reaction.count > 1 {
|
||||||
|
format!("{} {}", reaction.emoji, reaction.count)
|
||||||
} else {
|
} else {
|
||||||
if reaction.count > 1 {
|
reaction.emoji.clone()
|
||||||
format!("{} {}", reaction.emoji, reaction.count)
|
|
||||||
} else {
|
|
||||||
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()
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
.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),
|
||||||
|
)]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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("Нет"),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -384,7 +376,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
|||||||
if let Some(renderer) = &mut app.inline_image_renderer {
|
if let Some(renderer) = &mut app.inline_image_renderer {
|
||||||
// Загружаем только если видимо (early return если уже в кеше)
|
// Загружаем только если видимо (early return если уже в кеше)
|
||||||
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
||||||
|
|
||||||
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||||
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -76,7 +69,7 @@ pub fn render<T: TdClientTrait>(
|
|||||||
|
|
||||||
// Загружаем изображение (может занять время для iTerm2/Sixel)
|
// Загружаем изображение (может занять время для iTerm2/Sixel)
|
||||||
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
|
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
|
||||||
|
|
||||||
// Триггерим перерисовку для показа загруженного изображения
|
// Триггерим перерисовку для показа загруженного изображения
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,15 +14,13 @@ 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)
|
||||||
{
|
} else {
|
||||||
(messages.as_slice(), *selected_index)
|
return; // Некорректное состояние
|
||||||
} else {
|
};
|
||||||
return; // Некорректное состояние
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -105,10 +105,9 @@ 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());
|
||||||
assert_eq!(result.unwrap(), "success");
|
assert_eq!(result.unwrap(), "success");
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -65,16 +65,14 @@ 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| {
|
||||||
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
let output_before = buffer_to_string(&buffer_before);
|
let output_before = buffer_to_string(&buffer_before);
|
||||||
|
|
||||||
// Проверяем что нет "(1)" в первой строке чата
|
// Проверяем что нет "(1)" в первой строке чата
|
||||||
assert!(!output_before.contains("(1)"), "Before: should not contain (1)");
|
assert!(!output_before.contains("(1)"), "Before: should not contain (1)");
|
||||||
|
|
||||||
@@ -87,9 +85,13 @@ fn test_incoming_message_shows_unread_badge() {
|
|||||||
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
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]
|
||||||
@@ -127,39 +129,44 @@ async fn test_opening_chat_clears_unread_badge() {
|
|||||||
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
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);
|
||||||
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
|
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
|
||||||
|
|
||||||
// Собираем ID входящих сообщений (как в реальном коде)
|
// Собираем ID входящих сообщений (как в реальном коде)
|
||||||
let incoming_message_ids: Vec<MessageId> = loaded_messages
|
let incoming_message_ids: Vec<MessageId> = loaded_messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|msg| !msg.is_outgoing())
|
.filter(|msg| !msg.is_outgoing())
|
||||||
.map(|msg| msg.id())
|
.map(|msg| msg.id())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Проверяем что нашли 3 входящих сообщения
|
// Проверяем что нашли 3 входящих сообщения
|
||||||
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));
|
||||||
|
|
||||||
// Обрабатываем очередь (как в main loop)
|
// Обрабатываем очередь (как в main loop)
|
||||||
app.td_client.process_pending_view_messages().await;
|
app.td_client.process_pending_view_messages().await;
|
||||||
|
|
||||||
// В FakeTdClient это должно записаться в viewed_messages
|
// В FakeTdClient это должно записаться в viewed_messages
|
||||||
let viewed = app.td_client.get_viewed_messages();
|
let viewed = app.td_client.get_viewed_messages();
|
||||||
assert_eq!(viewed.len(), 1, "Should have one batch of viewed messages");
|
assert_eq!(viewed.len(), 1, "Should have one batch of viewed messages");
|
||||||
assert_eq!(viewed[0].0, 999, "Should be for chat 999");
|
assert_eq!(viewed[0].0, 999, "Should be for chat 999");
|
||||||
assert_eq!(viewed[0].1.len(), 3, "Should have viewed 3 messages");
|
assert_eq!(viewed[0].1.len(), 3, "Should have viewed 3 messages");
|
||||||
|
|
||||||
// В реальном приложении TDLib отправит Update::ChatReadInbox
|
// В реальном приложении TDLib отправит Update::ChatReadInbox
|
||||||
// который обновит unread_count в чате. Симулируем это:
|
// который обновит unread_count в чате. Симулируем это:
|
||||||
app.chats[0].unread_count = 0;
|
app.chats[0].unread_count = 0;
|
||||||
@@ -169,9 +176,13 @@ async fn test_opening_chat_clears_unread_badge() {
|
|||||||
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
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]
|
||||||
@@ -202,7 +213,7 @@ async fn test_opening_chat_loads_many_messages() {
|
|||||||
// Открываем чат - загружаем историю (запрашиваем 100 сообщений)
|
// Открываем чат - загружаем историю (запрашиваем 100 сообщений)
|
||||||
let chat_id = ChatId::new(888);
|
let chat_id = ChatId::new(888);
|
||||||
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
|
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
|
||||||
|
|
||||||
// Проверяем что загрузились ВСЕ 50 сообщений, а не только последние 2-3
|
// Проверяем что загрузились ВСЕ 50 сообщений, а не только последние 2-3
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded_messages.len(),
|
loaded_messages.len(),
|
||||||
@@ -244,7 +255,7 @@ async fn test_chat_history_chunked_loading() {
|
|||||||
// Тест 1: Загружаем 100 сообщений (больше чем 50, меньше чем 120)
|
// Тест 1: Загружаем 100 сообщений (больше чем 50, меньше чем 120)
|
||||||
let chat_id = ChatId::new(999);
|
let chat_id = ChatId::new(999);
|
||||||
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
|
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded_messages.len(),
|
loaded_messages.len(),
|
||||||
100,
|
100,
|
||||||
@@ -254,13 +265,13 @@ async fn test_chat_history_chunked_loading() {
|
|||||||
|
|
||||||
// Проверяем что сообщения в правильном порядке (от старых к новым)
|
// Проверяем что сообщения в правильном порядке (от старых к новым)
|
||||||
assert_eq!(loaded_messages[0].text(), "Message 1");
|
assert_eq!(loaded_messages[0].text(), "Message 1");
|
||||||
assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка
|
assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка
|
||||||
assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка
|
assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка
|
||||||
assert_eq!(loaded_messages[99].text(), "Message 100");
|
assert_eq!(loaded_messages[99].text(), "Message 100");
|
||||||
|
|
||||||
// Тест 2: Загружаем все 120 сообщений
|
// Тест 2: Загружаем все 120 сообщений
|
||||||
let all_messages = app.td_client.get_chat_history(chat_id, 120).await.unwrap();
|
let all_messages = app.td_client.get_chat_history(chat_id, 120).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
all_messages.len(),
|
all_messages.len(),
|
||||||
120,
|
120,
|
||||||
@@ -273,7 +284,7 @@ async fn test_chat_history_chunked_loading() {
|
|||||||
|
|
||||||
// Тест 3: Запрашиваем 200 сообщений, но есть только 120
|
// Тест 3: Запрашиваем 200 сообщений, но есть только 120
|
||||||
let limited_messages = app.td_client.get_chat_history(chat_id, 200).await.unwrap();
|
let limited_messages = app.td_client.get_chat_history(chat_id, 200).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
limited_messages.len(),
|
limited_messages.len(),
|
||||||
120,
|
120,
|
||||||
@@ -307,8 +318,12 @@ 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");
|
||||||
assert_eq!(all[199].text(), "Msg 200", "Last message should be newest");
|
assert_eq!(all[199].text(), "Msg 200", "Last message should be newest");
|
||||||
@@ -338,25 +353,29 @@ async fn test_load_older_messages_pagination() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let chat_id = ChatId::new(1002);
|
let chat_id = ChatId::new(1002);
|
||||||
|
|
||||||
// Шаг 1: Загружаем только последние 30 сообщений
|
// Шаг 1: Загружаем только последние 30 сообщений
|
||||||
// get_chat_history загружает от конца, поэтому получим сообщения 1-30
|
// get_chat_history загружает от конца, поэтому получим сообщения 1-30
|
||||||
let initial_batch = app.td_client.get_chat_history(chat_id, 30).await.unwrap();
|
let initial_batch = app.td_client.get_chat_history(chat_id, 30).await.unwrap();
|
||||||
assert_eq!(initial_batch.len(), 30, "Should load 30 messages initially");
|
assert_eq!(initial_batch.len(), 30, "Should load 30 messages initially");
|
||||||
assert_eq!(initial_batch[0].text(), "Msg 1", "First message should be Msg 1");
|
assert_eq!(initial_batch[0].text(), "Msg 1", "First message should be Msg 1");
|
||||||
assert_eq!(initial_batch[29].text(), "Msg 30", "Last should be Msg 30");
|
assert_eq!(initial_batch[29].text(), "Msg 30", "Last should be Msg 30");
|
||||||
|
|
||||||
// Шаг 2: Загружаем все 150 сообщений для проверки load_older
|
// Шаг 2: Загружаем все 150 сообщений для проверки load_older
|
||||||
let all_messages = app.td_client.get_chat_history(chat_id, 150).await.unwrap();
|
let all_messages = app.td_client.get_chat_history(chat_id, 150).await.unwrap();
|
||||||
assert_eq!(all_messages.len(), 150);
|
assert_eq!(all_messages.len(), 150);
|
||||||
|
|
||||||
// Имитируем ситуацию: у нас есть сообщения 101-150, хотим загрузить 51-100
|
// Имитируем ситуацию: у нас есть сообщения 101-150, хотим загрузить 51-100
|
||||||
// Берем ID сообщения 101 (первое в нашем "окне")
|
// Берем ID сообщения 101 (первое в нашем "окне")
|
||||||
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");
|
||||||
assert_eq!(older_batch[0].text(), "Msg 1", "Oldest should be Msg 1");
|
assert_eq!(older_batch[0].text(), "Msg 1", "Oldest should be Msg 1");
|
||||||
@@ -473,7 +492,7 @@ fn snapshot_chat_search_mode() {
|
|||||||
fn snapshot_chat_with_online_status() {
|
fn snapshot_chat_with_online_status() {
|
||||||
use tele_tui::tdlib::UserOnlineStatus;
|
use tele_tui::tdlib::UserOnlineStatus;
|
||||||
use tele_tui::types::ChatId;
|
use tele_tui::types::ChatId;
|
||||||
|
|
||||||
let chat = TestChatBuilder::new("Alice", 123)
|
let chat = TestChatBuilder::new("Alice", 123)
|
||||||
.last_message("Hey there!")
|
.last_message("Hey there!")
|
||||||
.build();
|
.build();
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
ChatId::new(789),
|
.send_message(
|
||||||
"Almost done! Just need to finish tests.".to_string(),
|
ChatId::new(789),
|
||||||
None,
|
"Almost done! Just need to finish tests.".to_string(),
|
||||||
None
|
None,
|
||||||
).await.unwrap();
|
None,
|
||||||
|
)
|
||||||
|
.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
|
||||||
ChatId::new(666),
|
.send_message(
|
||||||
"Sure, sending now!".to_string(),
|
ChatId::new(666),
|
||||||
Some(question_msg_id),
|
"Sure, sending now!".to_string(),
|
||||||
reply_info
|
Some(question_msg_id),
|
||||||
).await.unwrap();
|
reply_info,
|
||||||
|
)
|
||||||
|
.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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,17 +225,17 @@ impl TestAppBuilder {
|
|||||||
pub fn build(self) -> App<FakeTdClient> {
|
pub fn build(self) -> App<FakeTdClient> {
|
||||||
// Создаём FakeTdClient с чатами и сообщениями
|
// Создаём FakeTdClient с чатами и сообщениями
|
||||||
let mut fake_client = FakeTdClient::new();
|
let mut fake_client = FakeTdClient::new();
|
||||||
|
|
||||||
// Добавляем чаты
|
// Добавляем чаты
|
||||||
for chat in &self.chats {
|
for chat in &self.chats {
|
||||||
fake_client = fake_client.with_chat(chat.clone());
|
fake_client = fake_client.with_chat(chat.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем сообщения
|
// Добавляем сообщения
|
||||||
for (chat_id, messages) in self.messages {
|
for (chat_id, messages) in self.messages {
|
||||||
fake_client = fake_client.with_messages(chat_id, messages);
|
fake_client = fake_client.with_messages(chat_id, messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем текущий чат если нужно
|
// Устанавливаем текущий чат если нужно
|
||||||
if let Some(chat_id) = self.selected_chat_id {
|
if let Some(chat_id) = self.selected_chat_id {
|
||||||
*fake_client.current_chat_id.lock().unwrap() = Some(chat_id);
|
*fake_client.current_chat_id.lock().unwrap() = Some(chat_id);
|
||||||
@@ -244,7 +245,7 @@ impl TestAppBuilder {
|
|||||||
if let Some(auth_state) = self.auth_state {
|
if let Some(auth_state) = self.auth_state {
|
||||||
fake_client = fake_client.with_auth_state(auth_state);
|
fake_client = fake_client.with_auth_state(auth_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаём App с FakeTdClient
|
// Создаём App с FakeTdClient
|
||||||
let mut app = App::with_client(self.config, fake_client);
|
let mut app = App::with_client(self.config, fake_client);
|
||||||
|
|
||||||
@@ -254,7 +255,7 @@ impl TestAppBuilder {
|
|||||||
app.message_input = self.message_input;
|
app.message_input = self.message_input;
|
||||||
app.is_searching = self.is_searching;
|
app.is_searching = self.is_searching;
|
||||||
app.search_query = self.search_query;
|
app.search_query = self.search_query;
|
||||||
|
|
||||||
// Применяем chat_state если он установлен
|
// Применяем chat_state если он установлен
|
||||||
if let Some(chat_state) = self.chat_state {
|
if let Some(chat_state) = self.chat_state {
|
||||||
app.chat_state = chat_state;
|
app.chat_state = chat_state;
|
||||||
|
|||||||
@@ -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>>>,
|
||||||
@@ -30,14 +58,14 @@ pub struct FakeTdClient {
|
|||||||
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
|
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
|
||||||
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
|
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
|
||||||
pub available_reactions: Arc<Mutex<Vec<String>>>,
|
pub available_reactions: Arc<Mutex<Vec<String>>>,
|
||||||
|
|
||||||
// Состояние
|
// Состояние
|
||||||
pub network_state: Arc<Mutex<NetworkState>>,
|
pub network_state: Arc<Mutex<NetworkState>>,
|
||||||
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
|
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
|
||||||
pub current_chat_id: Arc<Mutex<Option<i64>>>,
|
pub current_chat_id: Arc<Mutex<Option<i64>>>,
|
||||||
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
|
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
|
||||||
pub auth_state: Arc<Mutex<AuthState>>,
|
pub auth_state: Arc<Mutex<AuthState>>,
|
||||||
|
|
||||||
// История действий (для проверки в тестах)
|
// История действий (для проверки в тестах)
|
||||||
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
|
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
|
||||||
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
|
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
|
||||||
@@ -45,12 +73,12 @@ pub struct FakeTdClient {
|
|||||||
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
|
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
|
||||||
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
|
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
|
||||||
pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids)
|
pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids)
|
||||||
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action)
|
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action)
|
||||||
pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные
|
pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные
|
||||||
|
|
||||||
// Update channel для симуляции событий
|
// Update channel для симуляции событий
|
||||||
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
||||||
|
|
||||||
// Скачанные файлы (file_id -> local_path)
|
// Скачанные файлы (file_id -> local_path)
|
||||||
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
||||||
|
|
||||||
@@ -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)),
|
||||||
@@ -164,14 +204,14 @@ impl FakeTdClient {
|
|||||||
fail_next_operation: Arc::new(Mutex::new(false)),
|
fail_next_operation: Arc::new(Mutex::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Создать update channel для получения событий
|
/// Создать update channel для получения событий
|
||||||
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
|
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
*self.update_tx.lock().unwrap() = Some(tx);
|
*self.update_tx.lock().unwrap() = Some(tx);
|
||||||
(self, rx)
|
(self, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Включить симуляцию задержек (как в реальном TDLib)
|
/// Включить симуляцию задержек (как в реальном TDLib)
|
||||||
pub fn with_delays(mut self) -> Self {
|
pub fn with_delays(mut self) -> Self {
|
||||||
self.simulate_delays = true;
|
self.simulate_delays = true;
|
||||||
@@ -179,7 +219,7 @@ impl FakeTdClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Builder Methods ====================
|
// ==================== Builder Methods ====================
|
||||||
|
|
||||||
/// Добавить чат
|
/// Добавить чат
|
||||||
pub fn with_chat(self, chat: ChatInfo) -> Self {
|
pub fn with_chat(self, chat: ChatInfo) -> Self {
|
||||||
self.chats.lock().unwrap().push(chat);
|
self.chats.lock().unwrap().push(chat);
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,10 +281,13 @@ impl FakeTdClient {
|
|||||||
*self.auth_state.lock().unwrap() = state;
|
*self.auth_state.lock().unwrap() = state;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Добавить скачанный файл (для 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,60 +298,76 @@ impl FakeTdClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Async TDLib Operations ====================
|
// ==================== Async TDLib Operations ====================
|
||||||
|
|
||||||
/// Загрузить список чатов
|
/// Загрузить список чатов
|
||||||
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
|
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
|
||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to load chats".to_string());
|
return Err("Failed to load chats".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.simulate_delays {
|
if self.simulate_delays {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Открыть чат
|
/// Открыть чат
|
||||||
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to open chat".to_string());
|
return Err("Failed to open chat".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
|
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить историю чата
|
/// Получить историю чата
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.simulate_delays {
|
if self.simulate_delays {
|
||||||
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())
|
||||||
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
|
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Ok(messages)
|
Ok(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Загрузить старые сообщения
|
/// Загрузить старые сообщения
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = self.messages.lock().unwrap();
|
let messages = self.messages.lock().unwrap();
|
||||||
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
|
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
|
||||||
|
|
||||||
// Найти индекс сообщения и вернуть предыдущие
|
// Найти индекс сообщения и вернуть предыдущие
|
||||||
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
|
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
|
||||||
let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect();
|
let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect();
|
||||||
@@ -329,24 +388,24 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to send message".to_string());
|
return Err("Failed to send message".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.simulate_delays {
|
if self.simulate_delays {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
|
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
|
||||||
|
|
||||||
self.sent_messages.lock().unwrap().push(SentMessage {
|
self.sent_messages.lock().unwrap().push(SentMessage {
|
||||||
chat_id: chat_id.as_i64(),
|
chat_id: chat_id.as_i64(),
|
||||||
text: text.clone(),
|
text: text.clone(),
|
||||||
reply_to,
|
reply_to,
|
||||||
reply_info: reply_info.clone(),
|
reply_info: reply_info.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let message = MessageInfo::new(
|
let message = MessageInfo::new(
|
||||||
message_id,
|
message_id,
|
||||||
"You".to_string(),
|
"You".to_string(),
|
||||||
true, // is_outgoing
|
true, // is_outgoing
|
||||||
text.clone(),
|
text.clone(),
|
||||||
vec![], // entities
|
vec![], // entities
|
||||||
chrono::Utc::now().timestamp() as i32,
|
chrono::Utc::now().timestamp() as i32,
|
||||||
@@ -356,10 +415,10 @@ impl FakeTdClient {
|
|||||||
true, // can_be_deleted_only_for_self
|
true, // can_be_deleted_only_for_self
|
||||||
true, // can_be_deleted_for_all_users
|
true, // can_be_deleted_for_all_users
|
||||||
reply_info,
|
reply_info,
|
||||||
None, // forward_from
|
None, // forward_from
|
||||||
vec![], // reactions
|
vec![], // reactions
|
||||||
);
|
);
|
||||||
|
|
||||||
// Добавляем в историю
|
// Добавляем в историю
|
||||||
self.messages
|
self.messages
|
||||||
.lock()
|
.lock()
|
||||||
@@ -367,16 +426,13 @@ impl FakeTdClient {
|
|||||||
.entry(chat_id.as_i64())
|
.entry(chat_id.as_i64())
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Редактировать сообщение
|
/// Редактировать сообщение
|
||||||
pub async fn edit_message(
|
pub async fn edit_message(
|
||||||
&self,
|
&self,
|
||||||
@@ -387,41 +443,37 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to edit message".to_string());
|
return Err("Failed to edit message".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.simulate_delays {
|
if self.simulate_delays {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.edited_messages.lock().unwrap().push(EditedMessage {
|
self.edited_messages.lock().unwrap().push(EditedMessage {
|
||||||
chat_id: chat_id.as_i64(),
|
chat_id: chat_id.as_i64(),
|
||||||
message_id,
|
message_id,
|
||||||
new_text: new_text.clone(),
|
new_text: new_text.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновляем сообщение
|
// Обновляем сообщение
|
||||||
let mut messages = self.messages.lock().unwrap();
|
let mut messages = self.messages.lock().unwrap();
|
||||||
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||||
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
||||||
msg.content.text = new_text.clone();
|
msg.content.text = new_text.clone();
|
||||||
msg.metadata.edit_date = msg.metadata.date + 60;
|
msg.metadata.edit_date = msg.metadata.date + 60;
|
||||||
|
|
||||||
let updated = msg.clone();
|
let updated = msg.clone();
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("Message not found".to_string())
|
Err("Message not found".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Удалить сообщения
|
/// Удалить сообщения
|
||||||
pub async fn delete_messages(
|
pub async fn delete_messages(
|
||||||
&self,
|
&self,
|
||||||
@@ -432,33 +484,30 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to delete messages".to_string());
|
return Err("Failed to delete messages".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.simulate_delays {
|
if self.simulate_delays {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.deleted_messages.lock().unwrap().push(DeletedMessages {
|
self.deleted_messages.lock().unwrap().push(DeletedMessages {
|
||||||
chat_id: chat_id.as_i64(),
|
chat_id: chat_id.as_i64(),
|
||||||
message_ids: message_ids.clone(),
|
message_ids: message_ids.clone(),
|
||||||
revoke,
|
revoke,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Удаляем из истории
|
// Удаляем из истории
|
||||||
let mut messages = self.messages.lock().unwrap();
|
let mut messages = self.messages.lock().unwrap();
|
||||||
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||||
chat_msgs.retain(|m| !message_ids.contains(&m.id()));
|
chat_msgs.retain(|m| !message_ids.contains(&m.id()));
|
||||||
}
|
}
|
||||||
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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Переслать сообщения
|
/// Переслать сообщения
|
||||||
pub async fn forward_messages(
|
pub async fn forward_messages(
|
||||||
&self,
|
&self,
|
||||||
@@ -469,26 +518,33 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to forward messages".to_string());
|
return Err("Failed to forward messages".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.simulate_delays {
|
if self.simulate_delays {
|
||||||
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
|
||||||
from_chat_id: from_chat_id.as_i64(),
|
.lock()
|
||||||
to_chat_id: to_chat_id.as_i64(),
|
.unwrap()
|
||||||
message_ids,
|
.push(ForwardedMessages {
|
||||||
});
|
from_chat_id: from_chat_id.as_i64(),
|
||||||
|
to_chat_id: to_chat_id.as_i64(),
|
||||||
|
message_ids,
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Поиск сообщений в чате
|
/// Поиск сообщений в чате
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = self.messages.lock().unwrap();
|
let messages = self.messages.lock().unwrap();
|
||||||
let results: Vec<_> = messages
|
let results: Vec<_> = messages
|
||||||
.get(&chat_id.as_i64())
|
.get(&chat_id.as_i64())
|
||||||
@@ -499,43 +555,49 @@ impl FakeTdClient {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
self.searched_queries.lock().unwrap().push(SearchQuery {
|
self.searched_queries.lock().unwrap().push(SearchQuery {
|
||||||
chat_id: chat_id.as_i64(),
|
chat_id: chat_id.as_i64(),
|
||||||
query: query.to_string(),
|
query: query.to_string(),
|
||||||
results_count: results.len(),
|
results_count: results.len(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить черновик
|
/// Установить черновик
|
||||||
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||||
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 {
|
||||||
chat_id,
|
chat_id,
|
||||||
draft_text: if text.is_empty() { None } else { Some(text) },
|
draft_text: if text.is_empty() { None } else { Some(text) },
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отправить действие в чате (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());
|
||||||
} else if action == "Cancel" {
|
} else if action == "Cancel" {
|
||||||
*self.typing_chat_id.lock().unwrap() = None;
|
*self.typing_chat_id.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить доступные реакции для сообщения
|
/// Получить доступные реакции для сообщения
|
||||||
pub async fn get_message_available_reactions(
|
pub async fn get_message_available_reactions(
|
||||||
&self,
|
&self,
|
||||||
@@ -545,10 +607,10 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to get available reactions".to_string());
|
return Err("Failed to get available reactions".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(self.available_reactions.lock().unwrap().clone())
|
Ok(self.available_reactions.lock().unwrap().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить/удалить реакцию
|
/// Установить/удалить реакцию
|
||||||
pub async fn toggle_reaction(
|
pub async fn toggle_reaction(
|
||||||
&self,
|
&self,
|
||||||
@@ -559,15 +621,18 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to toggle reaction".to_string());
|
return Err("Failed to toggle reaction".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем реакции на сообщении
|
// Обновляем реакции на сообщении
|
||||||
let mut messages = self.messages.lock().unwrap();
|
let mut messages = self.messages.lock().unwrap();
|
||||||
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||||
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
||||||
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) {
|
||||||
@@ -582,10 +647,10 @@ impl FakeTdClient {
|
|||||||
is_chosen: true,
|
is_chosen: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated_reactions = reactions.clone();
|
let updated_reactions = reactions.clone();
|
||||||
drop(messages);
|
drop(messages);
|
||||||
|
|
||||||
// Отправляем Update
|
// Отправляем Update
|
||||||
self.send_update(TdUpdate::MessageInteractionInfo {
|
self.send_update(TdUpdate::MessageInteractionInfo {
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -594,10 +659,10 @@ impl FakeTdClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Скачать файл (mock)
|
/// Скачать файл (mock)
|
||||||
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
@@ -617,7 +682,7 @@ impl FakeTdClient {
|
|||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to get profile info".to_string());
|
return Err("Failed to get profile info".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
self.profiles
|
self.profiles
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -625,7 +690,7 @@ impl FakeTdClient {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| "Profile not found".to_string())
|
.ok_or_else(|| "Profile not found".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отметить сообщения как просмотренные
|
/// Отметить сообщения как просмотренные
|
||||||
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||||
self.viewed_messages
|
self.viewed_messages
|
||||||
@@ -633,25 +698,25 @@ impl FakeTdClient {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
|
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Загрузить чаты папки
|
/// Загрузить чаты папки
|
||||||
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
|
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
|
||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
return Err("Failed to load folder chats".to_string());
|
return Err("Failed to load folder chats".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Helper Methods ====================
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
/// Отправить update в канал (если он установлен)
|
/// Отправить update в канал (если он установлен)
|
||||||
fn send_update(&self, update: TdUpdate) {
|
fn send_update(&self, update: TdUpdate) {
|
||||||
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
|
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
|
||||||
let _ = tx.send(update);
|
let _ = tx.send(update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверить нужно ли симулировать ошибку
|
/// Проверить нужно ли симулировать ошибку
|
||||||
fn should_fail(&self) -> bool {
|
fn should_fail(&self) -> bool {
|
||||||
let mut fail = self.fail_next_operation.lock().unwrap();
|
let mut fail = self.fail_next_operation.lock().unwrap();
|
||||||
@@ -662,16 +727,16 @@ impl FakeTdClient {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Симулировать ошибку в следующей операции
|
/// Симулировать ошибку в следующей операции
|
||||||
pub fn fail_next(&self) {
|
pub fn fail_next(&self) {
|
||||||
*self.fail_next_operation.lock().unwrap() = true;
|
*self.fail_next_operation.lock().unwrap() = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Симулировать входящее сообщение
|
/// Симулировать входящее сообщение
|
||||||
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
|
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
|
||||||
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
|
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
|
||||||
|
|
||||||
let message = MessageInfo::new(
|
let message = MessageInfo::new(
|
||||||
message_id,
|
message_id,
|
||||||
sender_name.to_string(),
|
sender_name.to_string(),
|
||||||
@@ -688,7 +753,7 @@ impl FakeTdClient {
|
|||||||
None,
|
None,
|
||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Добавляем в историю
|
// Добавляем в историю
|
||||||
self.messages
|
self.messages
|
||||||
.lock()
|
.lock()
|
||||||
@@ -696,26 +761,22 @@ impl FakeTdClient {
|
|||||||
.entry(chat_id.as_i64())
|
.entry(chat_id.as_i64())
|
||||||
.or_insert_with(Vec::new)
|
.or_insert_with(Vec::new)
|
||||||
.push(message.clone());
|
.push(message.clone());
|
||||||
|
|
||||||
// Отправляем Update
|
// Отправляем Update
|
||||||
self.send_update(TdUpdate::NewMessage { chat_id, message });
|
self.send_update(TdUpdate::NewMessage { chat_id, message });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Симулировать 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(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Симулировать изменение состояния сети
|
/// Симулировать изменение состояния сети
|
||||||
pub fn simulate_network_change(&self, state: NetworkState) {
|
pub fn simulate_network_change(&self, state: NetworkState) {
|
||||||
*self.network_state.lock().unwrap() = state.clone();
|
*self.network_state.lock().unwrap() = state.clone();
|
||||||
self.send_update(TdUpdate::ConnectionState { state });
|
self.send_update(TdUpdate::ConnectionState { state });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Симулировать прочтение сообщений
|
/// Симулировать прочтение сообщений
|
||||||
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
|
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
|
||||||
self.send_update(TdUpdate::ChatReadOutbox {
|
self.send_update(TdUpdate::ChatReadOutbox {
|
||||||
@@ -723,9 +784,9 @@ impl FakeTdClient {
|
|||||||
last_read_outbox_message_id: last_read_message_id,
|
last_read_outbox_message_id: last_read_message_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Getters for Test Assertions ====================
|
// ==================== Getters for Test Assertions ====================
|
||||||
|
|
||||||
/// Получить все чаты
|
/// Получить все чаты
|
||||||
pub fn get_chats(&self) -> Vec<ChatInfo> {
|
pub fn get_chats(&self) -> Vec<ChatInfo> {
|
||||||
self.chats.lock().unwrap().clone()
|
self.chats.lock().unwrap().clone()
|
||||||
@@ -745,57 +806,57 @@ impl FakeTdClient {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить отправленные сообщения
|
/// Получить отправленные сообщения
|
||||||
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
|
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
|
||||||
self.sent_messages.lock().unwrap().clone()
|
self.sent_messages.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить отредактированные сообщения
|
/// Получить отредактированные сообщения
|
||||||
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
|
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
|
||||||
self.edited_messages.lock().unwrap().clone()
|
self.edited_messages.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить удалённые сообщения
|
/// Получить удалённые сообщения
|
||||||
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
|
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
|
||||||
self.deleted_messages.lock().unwrap().clone()
|
self.deleted_messages.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить пересланные сообщения
|
/// Получить пересланные сообщения
|
||||||
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
|
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
|
||||||
self.forwarded_messages.lock().unwrap().clone()
|
self.forwarded_messages.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить поисковые запросы
|
/// Получить поисковые запросы
|
||||||
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
|
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
|
||||||
self.searched_queries.lock().unwrap().clone()
|
self.searched_queries.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить просмотренные сообщения
|
/// Получить просмотренные сообщения
|
||||||
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
|
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
|
||||||
self.viewed_messages.lock().unwrap().clone()
|
self.viewed_messages.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить действия в чатах
|
/// Получить действия в чатах
|
||||||
pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
|
pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
|
||||||
self.chat_actions.lock().unwrap().clone()
|
self.chat_actions.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить текущее состояние сети
|
/// Получить текущее состояние сети
|
||||||
pub fn get_network_state(&self) -> NetworkState {
|
pub fn get_network_state(&self) -> NetworkState {
|
||||||
self.network_state.lock().unwrap().clone()
|
self.network_state.lock().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить ID текущего открытого чата
|
/// Получить ID текущего открытого чата
|
||||||
pub fn get_current_chat_id(&self) -> Option<i64> {
|
pub fn get_current_chat_id(&self) -> Option<i64> {
|
||||||
*self.current_chat_id.lock().unwrap()
|
*self.current_chat_id.lock().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Установить update channel для получения событий
|
/// Установить update channel для получения событий
|
||||||
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
|
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
|
||||||
*self.update_tx.lock().unwrap() = Some(tx);
|
*self.update_tx.lock().unwrap() = Some(tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Очистить всю историю действий
|
/// Очистить всю историю действий
|
||||||
pub fn clear_all_history(&self) {
|
pub fn clear_all_history(&self) {
|
||||||
self.sent_messages.lock().unwrap().clear();
|
self.sent_messages.lock().unwrap().clear();
|
||||||
@@ -835,10 +896,12 @@ mod tests {
|
|||||||
async fn test_send_message() {
|
async fn test_send_message() {
|
||||||
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();
|
||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.len(), 1);
|
||||||
assert_eq!(sent[0].text, "Hello");
|
assert_eq!(sent[0].text, "Hello");
|
||||||
@@ -849,12 +912,17 @@ mod tests {
|
|||||||
async fn test_edit_message() {
|
async fn test_edit_message() {
|
||||||
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);
|
||||||
assert_eq!(client.get_messages(123)[0].text(), "Hello World");
|
assert_eq!(client.get_messages(123)[0].text(), "Hello World");
|
||||||
@@ -865,25 +933,30 @@ mod tests {
|
|||||||
async fn test_delete_message() {
|
async fn test_delete_message() {
|
||||||
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;
|
||||||
|
|
||||||
let deleted = client.get_deleted_messages();
|
let deleted = client.get_deleted_messages();
|
||||||
assert_eq!(deleted.len(), 1);
|
assert_eq!(deleted.len(), 1);
|
||||||
assert_eq!(client.get_messages(123).len(), 0);
|
assert_eq!(client.get_messages(123).len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_update_channel() {
|
async fn test_update_channel() {
|
||||||
let (client, mut rx) = FakeTdClient::new().with_update_channel();
|
let (client, mut rx) = FakeTdClient::new().with_update_channel();
|
||||||
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 {
|
||||||
match update {
|
match update {
|
||||||
@@ -896,39 +969,43 @@ mod tests {
|
|||||||
panic!("No update received");
|
panic!("No update received");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_simulate_incoming_message() {
|
async fn test_simulate_incoming_message() {
|
||||||
let (client, mut rx) = FakeTdClient::new().with_update_channel();
|
let (client, mut rx) = FakeTdClient::new().with_update_channel();
|
||||||
let chat_id = ChatId::new(123);
|
let chat_id = ChatId::new(123);
|
||||||
|
|
||||||
client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob");
|
client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob");
|
||||||
|
|
||||||
// Проверяем Update
|
// Проверяем Update
|
||||||
if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await {
|
if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await {
|
||||||
assert_eq!(message.text(), "Hello from Bob");
|
assert_eq!(message.text(), "Hello from Bob");
|
||||||
assert_eq!(message.sender_name(), "Bob");
|
assert_eq!(message.sender_name(), "Bob");
|
||||||
assert!(!message.is_outgoing());
|
assert!(!message.is_outgoing());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем что сообщение добавилось
|
// Проверяем что сообщение добавилось
|
||||||
assert_eq!(client.get_messages(123).len(), 1);
|
assert_eq!(client.get_messages(123).len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_fail_next_operation() {
|
async fn test_fail_next_operation() {
|
||||||
let client = FakeTdClient::new();
|
let client = FakeTdClient::new();
|
||||||
let chat_id = ChatId::new(123);
|
let chat_id = ChatId::new(123);
|
||||||
|
|
||||||
// Устанавливаем флаг ошибки
|
// Устанавливаем флаг ошибки
|
||||||
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,13 +207,17 @@ impl TdClientTrait for FakeTdClient {
|
|||||||
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
|
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
|
||||||
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
|
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
|
||||||
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
|
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
|
||||||
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user