Split core and TUI crates

This commit is contained in:
Mikhail Kilin
2026-05-20 00:31:18 +03:00
parent 91a8700b8e
commit eefac431e5
238 changed files with 624 additions and 191 deletions

View File

@@ -0,0 +1,66 @@
[package]
name = "tele-tui"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <your.email@example.com>"]
description = "Terminal UI for Telegram with Vim-style navigation"
license = "MIT"
repository = "https://github.com/your-username/tele-tui"
keywords = ["telegram", "tui", "terminal", "cli"]
categories = ["command-line-utilities"]
default-run = "tele-tui"
[features]
default = ["clipboard", "url-open", "notifications", "images"]
clipboard = ["dep:arboard"]
url-open = ["dep:open"]
notifications = ["dep:notify-rust"]
images = ["dep:ratatui-image", "dep:image", "tele-core/images"]
test-support = ["tele-core/test-support"]
[dependencies]
tele-core = { path = "../tele-core" }
ratatui = "0.29"
crossterm = "0.28"
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
chrono = "0.4"
open = { version = "5.0", optional = true }
arboard = { version = "3.4", optional = true }
notify-rust = { version = "4.11", optional = true }
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
image = { version = "0.25", optional = true }
toml = "0.8"
dirs = "5.0"
thiserror = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
base64 = "0.22.1"
fs2 = "0.4"
[dev-dependencies]
insta = "1.34"
tokio-test = "0.4"
criterion = "0.5"
termwright = "0.2"
[[bin]]
name = "tele-tui-test-fixture"
path = "src/bin/tele-tui-test-fixture.rs"
required-features = ["test-support"]
[[bench]]
name = "group_messages"
harness = false
[[bench]]
name = "formatting"
harness = false
[[bench]]
name = "format_markdown"
harness = false

View File

@@ -0,0 +1,88 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use ratatui::style::Color;
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string();
let entities = vec![
TextEntity {
offset: 8,
length: 4, // bold
r#type: TextEntityType::Bold,
},
TextEntity {
offset: 17,
length: 6, // italic
r#type: TextEntityType::Italic,
},
TextEntity {
offset: 34,
length: 4, // code
r#type: TextEntityType::Code,
},
TextEntity {
offset: 45,
length: 4, // link
r#type: TextEntityType::Url,
},
TextEntity {
offset: 54,
length: 7, // mention
r#type: TextEntityType::Mention,
},
];
(text, entities)
}
fn benchmark_format_simple_text(c: &mut Criterion) {
let text = "Simple text without any formatting".to_string();
let entities = vec![];
c.bench_function("format_simple_text", |b| {
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
});
}
fn benchmark_format_markdown_text(c: &mut Criterion) {
let (text, entities) = create_text_with_entities();
c.bench_function("format_markdown_text", |b| {
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
});
}
fn benchmark_format_long_text(c: &mut Criterion) {
let mut text = String::new();
let mut entities = vec![];
// Создаем длинный текст с множеством форматирований
for i in 0..100 {
let start = text.len();
text.push_str(&format!("Word{} ", i));
// Добавляем форматирование к каждому 3-му слову
if i % 3 == 0 {
entities.push(TextEntity {
offset: start as i32,
length: format!("Word{}", i).len() as i32,
r#type: TextEntityType::Bold,
});
}
}
c.bench_function("format_long_text_with_100_entities", |b| {
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
});
}
criterion_group!(
benches,
benchmark_format_simple_text,
benchmark_format_markdown_text,
benchmark_format_long_text
);
criterion_main!(benches);

View File

@@ -0,0 +1,38 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::utils::formatting::{format_date, format_timestamp, get_day};
fn benchmark_format_timestamp(c: &mut Criterion) {
c.bench_function("format_timestamp_50_times", |b| {
b.iter(|| {
for i in 0..50 {
let timestamp = 1640000000 + (i * 60);
black_box(format_timestamp(timestamp));
}
});
});
}
fn benchmark_format_date(c: &mut Criterion) {
c.bench_function("format_date_50_times", |b| {
b.iter(|| {
for i in 0..50 {
let timestamp = 1640000000 + (i * 86400);
black_box(format_date(timestamp));
}
});
});
}
fn benchmark_get_day(c: &mut Criterion) {
c.bench_function("get_day_1000_times", |b| {
b.iter(|| {
for i in 0..1000 {
let timestamp = 1640000000 + (i * 60);
black_box(get_day(timestamp));
}
});
});
}
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
criterion_main!(benches);

View File

@@ -0,0 +1,43 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::message_grouping::group_messages;
use tele_tui::tdlib::types::MessageBuilder;
use tele_tui::types::MessageId;
fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
(0..count)
.map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(format!("User{}", i % 10))
.text(format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.date(1640000000 + (i as i32 * 60));
if i % 2 == 0 {
builder.outgoing().read().build()
} else {
builder.incoming().build()
}
})
.collect()
}
fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100);
c.bench_function("group_100_messages", |b| {
b.iter(|| group_messages(black_box(&messages)));
});
}
fn benchmark_group_500_messages(c: &mut Criterion) {
let messages = create_test_messages(500);
c.bench_function("group_500_messages", |b| {
b.iter(|| group_messages(black_box(&messages)));
});
}
criterion_group!(benches, benchmark_group_100_messages, benchmark_group_500_messages);
criterion_main!(benches);

38
crates/tele-tui/build.rs Normal file
View File

@@ -0,0 +1,38 @@
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=build.rs");
for lib_dir in tdlib_lib_dirs() {
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display());
}
}
fn tdlib_lib_dirs() -> Vec<PathBuf> {
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
let workspace_dir = manifest_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.unwrap_or(manifest_dir);
let build_dir = workspace_dir.join("target").join(profile).join("build");
let Ok(entries) = fs::read_dir(build_dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path().join("out").join("tdlib").join("lib"))
.filter(|path| has_tdjson(path))
.collect()
}
fn has_tdjson(path: &Path) -> bool {
path.join("libtdjson.1.8.29.dylib").exists()
|| path.join("libtdjson.dylib").exists()
|| path.join("libtdjson.so").exists()
}

View File

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

View File

@@ -0,0 +1,202 @@
//! Account manager: loading, saving, migration, and resolution.
//!
//! Handles `accounts.toml` lifecycle and legacy `./tdlib_data/` migration
//! to XDG data directory.
use std::fs;
use std::path::PathBuf;
use super::profile::{account_db_path, validate_account_name, AccountsConfig};
/// Returns the path to `accounts.toml` in the config directory.
///
/// `~/.config/tele-tui/accounts.toml`
pub fn accounts_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("accounts.toml");
path
})
}
/// Loads `accounts.toml` or creates it with default values.
///
/// On first run, also attempts to migrate legacy `./tdlib_data/` directory
/// to the XDG data location.
pub fn load_or_create() -> AccountsConfig {
let config_path = match accounts_config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory for accounts, using defaults");
return AccountsConfig::default_single();
}
};
if config_path.exists() {
// Load existing config
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<AccountsConfig>(&content) {
Ok(config) => return config,
Err(e) => {
tracing::warn!("Could not parse accounts.toml: {}", e);
return AccountsConfig::default_single();
}
},
Err(e) => {
tracing::warn!("Could not read accounts.toml: {}", e);
return AccountsConfig::default_single();
}
}
}
// First run: migrate legacy data if present, then create default config
migrate_legacy();
let config = AccountsConfig::default_single();
if let Err(e) = save(&config) {
tracing::warn!("Could not save initial accounts.toml: {}", e);
}
config
}
/// Saves `AccountsConfig` to `accounts.toml`.
pub fn save(config: &AccountsConfig) -> Result<(), String> {
let config_path =
accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?;
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Could not create config directory: {}", e))?;
}
let toml_string = toml::to_string_pretty(config)
.map_err(|e| format!("Could not serialize accounts config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write accounts.toml: {}", e))?;
Ok(())
}
/// Migrates legacy `./tdlib_data/` from CWD to XDG data dir.
///
/// If `./tdlib_data/` exists in the current working directory, moves it to
/// `~/.local/share/tele-tui/accounts/default/tdlib_data/`.
fn migrate_legacy() {
let legacy_path = PathBuf::from("tdlib_data");
if !legacy_path.exists() || !legacy_path.is_dir() {
return;
}
let target = account_db_path("default");
// Don't overwrite if target already exists
if target.exists() {
tracing::info!(
"Legacy ./tdlib_data/ found but target already exists at {}, skipping migration",
target.display()
);
return;
}
// Create parent directories
if let Some(parent) = target.parent() {
if let Err(e) = fs::create_dir_all(parent) {
tracing::error!("Could not create target directory for migration: {}", e);
return;
}
}
// Move (rename) the directory
match fs::rename(&legacy_path, &target) {
Ok(()) => {
tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
}
Err(e) => {
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
}
}
}
/// Resolves which account to use from CLI arg or default.
///
/// # Arguments
///
/// * `config` - The loaded accounts configuration
/// * `account_arg` - Optional account name from `--account` CLI flag
///
/// # Returns
///
/// The resolved account name and its db_path.
///
/// # Errors
///
/// Returns an error if the specified account is not found or the name is invalid.
pub fn resolve_account(
config: &AccountsConfig,
account_arg: Option<&str>,
) -> Result<(String, PathBuf), String> {
let account_name = account_arg.unwrap_or(&config.default_account);
// Validate name
validate_account_name(account_name)?;
// Find account in config
let _account = config.find_account(account_name).ok_or_else(|| {
let available: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
format!(
"Account '{}' not found. Available accounts: {}",
account_name,
available.join(", ")
)
})?;
let db_path = account_db_path(account_name);
Ok((account_name.to_string(), db_path))
}
/// Adds a new account to `accounts.toml` and creates its data directory.
///
/// Validates the name, checks for duplicates, adds the profile to config,
/// saves the config, and creates the data directory.
///
/// # Returns
///
/// The db_path for the new account.
///
/// # Errors
///
/// Returns an error if the name is invalid, already exists, or I/O fails.
pub fn add_account(name: &str, display_name: &str) -> Result<std::path::PathBuf, String> {
validate_account_name(name)?;
let mut config = load_or_create();
// Check for duplicate
if config.find_account(name).is_some() {
return Err(format!("Account '{}' already exists", name));
}
// Add new profile
config.accounts.push(super::profile::AccountProfile {
name: name.to_string(),
display_name: display_name.to_string(),
});
// Save config
save(&config)?;
// Create data directory
ensure_account_dir(name)
}
/// Ensures the account data directory exists.
///
/// Creates `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` if needed.
pub fn ensure_account_dir(account_name: &str) -> Result<PathBuf, String> {
let db_path = account_db_path(account_name);
fs::create_dir_all(&db_path)
.map_err(|e| format!("Could not create account directory: {}", e))?;
Ok(db_path)
}

View File

@@ -0,0 +1,15 @@
//! Account profiles module for multi-account support.
//!
//! Manages account profiles stored in `~/.config/tele-tui/accounts.toml`.
//! Each account has its own TDLib database directory under
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
pub mod lock;
pub mod manager;
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};
#[allow(unused_imports)]
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

View File

@@ -0,0 +1,147 @@
//! Account profile data structures and validation.
//!
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountsConfig {
/// Name of the default account to use when no `--account` flag is provided.
pub default_account: String,
/// List of configured accounts.
pub accounts: Vec<AccountProfile>,
}
/// A single account profile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountProfile {
/// Unique identifier (used in directory names and CLI flag).
pub name: String,
/// Human-readable display name.
pub display_name: String,
}
impl AccountsConfig {
/// Creates a default config with a single "default" account.
pub fn default_single() -> Self {
Self {
default_account: "default".to_string(),
accounts: vec![AccountProfile {
name: "default".to_string(),
display_name: "Default".to_string(),
}],
}
}
/// Finds an account by name.
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
self.accounts.iter().find(|a| a.name == name)
}
}
impl AccountProfile {
/// Computes the TDLib database directory path for this account.
///
/// Returns `~/.local/share/tele-tui/accounts/{name}/tdlib_data`
/// (or platform equivalent via `dirs::data_dir()`).
pub fn db_path(&self) -> PathBuf {
account_db_path(&self.name)
}
}
/// Computes the TDLib database directory path for a given account name.
///
/// Returns `{data_dir}/tele-tui/accounts/{name}/tdlib_data`.
pub fn account_db_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("tdlib_data");
path
}
/// Validates an account name.
///
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
/// Must be 1-32 characters long.
///
/// # Errors
///
/// Returns a descriptive error message if the name is invalid.
pub fn validate_account_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Account name cannot be empty".to_string());
}
if name.len() > 32 {
return Err("Account name cannot be longer than 32 characters".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
return Err(
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
.to_string(),
);
}
if name.starts_with('-') || name.starts_with('_') {
return Err("Account name cannot start with a hyphen or underscore".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_account_name_valid() {
assert!(validate_account_name("default").is_ok());
assert!(validate_account_name("work").is_ok());
assert!(validate_account_name("my-account").is_ok());
assert!(validate_account_name("account_2").is_ok());
assert!(validate_account_name("a").is_ok());
}
#[test]
fn test_validate_account_name_invalid() {
assert!(validate_account_name("").is_err());
assert!(validate_account_name("My Account").is_err());
assert!(validate_account_name("UPPER").is_err());
assert!(validate_account_name("with spaces").is_err());
assert!(validate_account_name("-starts-with-dash").is_err());
assert!(validate_account_name("_starts-with-underscore").is_err());
assert!(validate_account_name(&"a".repeat(33)).is_err());
}
#[test]
fn test_default_single_config() {
let config = AccountsConfig::default_single();
assert_eq!(config.default_account, "default");
assert_eq!(config.accounts.len(), 1);
assert_eq!(config.accounts[0].name, "default");
}
#[test]
fn test_find_account() {
let config = AccountsConfig::default_single();
assert!(config.find_account("default").is_some());
assert!(config.find_account("nonexistent").is_none());
}
#[test]
fn test_db_path_contains_account_name() {
let path = account_db_path("work");
let path_str = path.to_string_lossy();
assert!(path_str.contains("tele-tui"));
assert!(path_str.contains("accounts"));
assert!(path_str.contains("work"));
assert!(path_str.ends_with("tdlib_data"));
}
}

View File

@@ -0,0 +1,87 @@
/// Состояние аутентификации
///
/// Отвечает за данные авторизации:
/// - Ввод номера телефона
/// - Ввод кода подтверждения
/// - Ввод пароля (2FA)
/// Состояние аутентификации
#[derive(Debug, Clone, Default)]
pub struct AuthState {
/// Введённый номер телефона
phone_input: String,
/// Введённый код подтверждения
code_input: String,
/// Введённый пароль (для 2FA)
password_input: String,
}
impl AuthState {
/// Создать новое состояние аутентификации
pub fn new() -> Self {
Self::default()
}
// === Phone input ===
pub fn phone_input(&self) -> &str {
&self.phone_input
}
pub fn phone_input_mut(&mut self) -> &mut String {
&mut self.phone_input
}
pub fn set_phone_input(&mut self, input: String) {
self.phone_input = input;
}
pub fn clear_phone_input(&mut self) {
self.phone_input.clear();
}
// === Code input ===
pub fn code_input(&self) -> &str {
&self.code_input
}
pub fn code_input_mut(&mut self) -> &mut String {
&mut self.code_input
}
pub fn set_code_input(&mut self, input: String) {
self.code_input = input;
}
pub fn clear_code_input(&mut self) {
self.code_input.clear();
}
// === Password input ===
pub fn password_input(&self) -> &str {
&self.password_input
}
pub fn password_input_mut(&mut self) -> &mut String {
&mut self.password_input
}
pub fn set_password_input(&mut self, input: String) {
self.password_input = input;
}
pub fn clear_password_input(&mut self) {
self.password_input.clear();
}
/// Очистить все поля ввода
pub fn clear_all(&mut self) {
self.phone_input.clear();
self.code_input.clear();
self.password_input.clear();
}
}

View File

@@ -0,0 +1,327 @@
/// Модуль для централизованной фильтрации чатов
///
/// Предоставляет единый источник правды для всех видов фильтрации:
/// - По папкам (folders)
/// - По поисковому запросу
/// - По статусу (archived, muted, и т.д.)
///
/// Используется как в App, так и в UI слое для консистентной фильтрации.
use crate::tdlib::ChatInfo;
/// Критерии фильтрации чатов
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct ChatFilterCriteria {
/// Фильтр по папке (folder_id)
pub folder_id: Option<i32>,
/// Поисковый запрос (по названию или username)
pub search_query: Option<String>,
/// Показывать только закреплённые
pub pinned_only: bool,
/// Показывать только непрочитанные
pub unread_only: bool,
/// Показывать только с упоминаниями
pub mentions_only: bool,
/// Скрывать muted чаты
pub hide_muted: bool,
/// Скрывать архивные чаты
pub hide_archived: bool,
}
#[allow(dead_code)]
impl ChatFilterCriteria {
/// Создаёт критерии с дефолтными значениями
pub fn new() -> Self {
Self::default()
}
/// Фильтр только по папке
pub fn by_folder(folder_id: Option<i32>) -> Self {
Self { folder_id, ..Default::default() }
}
/// Фильтр только по поисковому запросу
pub fn by_search(query: String) -> Self {
Self { search_query: Some(query), ..Default::default() }
}
/// Builder: установить папку
pub fn with_folder(mut self, folder_id: Option<i32>) -> Self {
self.folder_id = folder_id;
self
}
/// Builder: установить поисковый запрос
pub fn with_search(mut self, query: String) -> Self {
self.search_query = Some(query);
self
}
/// Builder: показывать только закреплённые
pub fn pinned_only(mut self, enabled: bool) -> Self {
self.pinned_only = enabled;
self
}
/// Builder: показывать только непрочитанные
pub fn unread_only(mut self, enabled: bool) -> Self {
self.unread_only = enabled;
self
}
/// Builder: показывать только с упоминаниями
pub fn mentions_only(mut self, enabled: bool) -> Self {
self.mentions_only = enabled;
self
}
/// Builder: скрывать muted
pub fn hide_muted(mut self, enabled: bool) -> Self {
self.hide_muted = enabled;
self
}
/// Builder: скрывать архивные
pub fn hide_archived(mut self, enabled: bool) -> Self {
self.hide_archived = enabled;
self
}
/// Проверяет подходит ли чат под все критерии
pub fn matches(&self, chat: &ChatInfo) -> bool {
// Фильтр по папке
if let Some(folder_id) = self.folder_id {
if !chat.folder_ids.contains(&folder_id) {
return false;
}
}
// Фильтр по поисковому запросу
if let Some(ref query) = self.search_query {
if !query.is_empty() {
let query_lower = query.to_lowercase();
let title_matches = chat.title.to_lowercase().contains(&query_lower);
let username_matches = chat
.username
.as_ref()
.map(|u| u.to_lowercase().contains(&query_lower))
.unwrap_or(false);
if !title_matches && !username_matches {
return false;
}
}
}
// Только закреплённые
if self.pinned_only && !chat.is_pinned {
return false;
}
// Только непрочитанные
if self.unread_only && chat.unread_count == 0 {
return false;
}
// Только с упоминаниями
if self.mentions_only && chat.unread_mention_count == 0 {
return false;
}
// Скрывать muted
if self.hide_muted && chat.is_muted {
return false;
}
// Скрывать архивные (folder_id == 1)
if self.hide_archived && chat.folder_ids.contains(&1) {
return false;
}
true
}
}
/// Централизованный фильтр чатов
#[allow(dead_code)]
pub struct ChatFilter;
#[allow(dead_code)]
impl ChatFilter {
/// Фильтрует список чатов по критериям
///
/// # Arguments
///
/// * `chats` - Исходный список чатов
/// * `criteria` - Критерии фильтрации
///
/// # Returns
///
/// Отфильтрованный список чатов (без клонирования, только references)
///
/// # Examples
///
/// ```ignore
/// let criteria = ChatFilterCriteria::by_folder(Some(0))
/// .with_search("John".to_string());
///
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
/// ```
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
chats.iter().filter(|chat| criteria.matches(chat)).collect()
}
/// Фильтрует чаты по папке
///
/// Упрощённая версия для наиболее частого случая.
pub fn by_folder(chats: &[ChatInfo], folder_id: Option<i32>) -> Vec<&ChatInfo> {
let criteria = ChatFilterCriteria::by_folder(folder_id);
Self::filter(chats, &criteria)
}
/// Фильтрует чаты по поисковому запросу
///
/// Упрощённая версия для поиска.
pub fn by_search<'a>(chats: &'a [ChatInfo], query: &str) -> Vec<&'a ChatInfo> {
if query.is_empty() {
return chats.iter().collect();
}
let criteria = ChatFilterCriteria::by_search(query.to_string());
Self::filter(chats, &criteria)
}
/// Подсчитывает чаты подходящие под критерии
pub fn count(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> usize {
chats.iter().filter(|chat| criteria.matches(chat)).count()
}
/// Подсчитывает непрочитанные сообщения в отфильтрованных чатах
pub fn count_unread(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
chats
.iter()
.filter(|chat| criteria.matches(chat))
.map(|chat| chat.unread_count)
.sum()
}
/// Подсчитывает непрочитанные упоминания в отфильтрованных чатах
pub fn count_unread_mentions(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
chats
.iter()
.filter(|chat| criteria.matches(chat))
.map(|chat| chat.unread_mention_count)
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ChatId;
#[allow(clippy::too_many_arguments)]
fn create_test_chat(
id: i64,
title: &str,
username: Option<&str>,
folder_ids: Vec<i32>,
unread: i32,
mentions: i32,
is_pinned: bool,
is_muted: bool,
) -> ChatInfo {
use crate::types::MessageId;
ChatInfo {
id: ChatId::new(id),
title: title.to_string(),
username: username.map(String::from),
folder_ids,
unread_count: unread,
unread_mention_count: mentions,
is_pinned,
is_muted,
last_message_date: 0,
last_message: String::new(),
order: 0,
last_read_outbox_message_id: MessageId::new(0),
draft_text: None,
}
}
#[test]
fn test_filter_by_folder() {
let chats = vec![
create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false),
create_test_chat(2, "Chat 2", None, vec![1], 0, 0, false, false),
create_test_chat(3, "Chat 3", None, vec![0, 1], 0, 0, false, false),
];
let filtered = ChatFilter::by_folder(&chats, Some(0));
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3
assert_eq!(filtered[0].id.as_i64(), 1);
assert_eq!(filtered[1].id.as_i64(), 3);
}
#[test]
fn test_filter_by_search() {
let chats = vec![
create_test_chat(1, "John Doe", Some("johndoe"), vec![0], 0, 0, false, false),
create_test_chat(2, "Jane Smith", Some("janesmith"), vec![0], 0, 0, false, false),
create_test_chat(3, "Bob Johnson", None, vec![0], 0, 0, false, false),
];
// Поиск по имени
let filtered = ChatFilter::by_search(&chats, "john");
assert_eq!(filtered.len(), 2); // John Doe and Bob Johnson
// Поиск по username
let filtered = ChatFilter::by_search(&chats, "smith");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].title, "Jane Smith");
}
#[test]
fn test_filter_criteria_builder() {
let chats = vec![
create_test_chat(1, "Chat 1", None, vec![0], 5, 0, true, false),
create_test_chat(2, "Chat 2", None, vec![0], 0, 0, false, false),
create_test_chat(3, "Chat 3", None, vec![0], 10, 2, false, false),
];
let criteria = ChatFilterCriteria::new()
.with_folder(Some(0))
.unread_only(true)
.pinned_only(false);
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
let criteria = ChatFilterCriteria::new().pinned_only(true);
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
}
#[test]
fn test_count_methods() {
let chats = vec![
create_test_chat(1, "Chat 1", None, vec![0], 5, 1, false, false),
create_test_chat(2, "Chat 2", None, vec![0], 10, 2, false, false),
create_test_chat(3, "Chat 3", None, vec![1], 3, 0, false, false),
];
let criteria = ChatFilterCriteria::by_folder(Some(0));
assert_eq!(ChatFilter::count(&chats, &criteria), 2);
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
}
}

View File

@@ -0,0 +1,195 @@
/// Состояние списка чатов
///
/// Отвечает за:
/// - Список чатов
/// - Выбранный чат в списке
/// - Фильтрацию по папкам
/// - Поиск чатов
use crate::app::chat_filter::{ChatFilter, ChatFilterCriteria};
use crate::tdlib::ChatInfo;
use ratatui::widgets::ListState;
/// Состояние списка чатов
#[derive(Debug)]
pub struct ChatListState {
/// Список всех чатов
pub chats: Vec<ChatInfo>,
/// Состояние виджета списка (выбранный индекс)
pub list_state: ListState,
/// Выбранная папка (None = All, Some(id) = конкретная папка)
pub selected_folder_id: Option<i32>,
/// Флаг режима поиска чатов
pub is_searching: bool,
/// Поисковый запрос для фильтрации чатов
pub search_query: String,
}
impl Default for ChatListState {
fn default() -> Self {
let mut state = ListState::default();
state.select(Some(0));
Self {
chats: Vec::new(),
list_state: state,
selected_folder_id: None,
is_searching: false,
search_query: String::new(),
}
}
}
impl ChatListState {
/// Создать новое состояние списка чатов
pub fn new() -> Self {
Self::default()
}
// === Chats ===
pub fn chats(&self) -> &[ChatInfo] {
&self.chats
}
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
&mut self.chats
}
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
self.chats = chats;
}
pub fn add_chat(&mut self, chat: ChatInfo) {
self.chats.push(chat);
}
pub fn clear_chats(&mut self) {
self.chats.clear();
}
// === List state (selection) ===
pub fn list_state(&self) -> &ListState {
&self.list_state
}
pub fn list_state_mut(&mut self) -> &mut ListState {
&mut self.list_state
}
pub fn selected_index(&self) -> Option<usize> {
self.list_state.selected()
}
pub fn select(&mut self, index: Option<usize>) {
self.list_state.select(index);
}
// === Folder ===
pub fn selected_folder_id(&self) -> Option<i32> {
self.selected_folder_id
}
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
self.selected_folder_id = id;
}
// === Search ===
pub fn is_searching(&self) -> bool {
self.is_searching
}
pub fn set_searching(&mut self, searching: bool) {
self.is_searching = searching;
}
pub fn search_query(&self) -> &str {
&self.search_query
}
pub fn search_query_mut(&mut self) -> &mut String {
&mut self.search_query
}
pub fn set_search_query(&mut self, query: String) {
self.search_query = query;
}
pub fn start_search(&mut self) {
self.is_searching = true;
self.search_query.clear();
}
pub fn cancel_search(&mut self) {
self.is_searching = false;
self.search_query.clear();
self.list_state.select(Some(0));
}
// === Navigation ===
/// Получить отфильтрованный список чатов
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone());
}
ChatFilter::filter(&self.chats, &criteria)
}
/// Выбрать следующий чат
pub fn next_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
/// Выбрать предыдущий чат
pub fn previous_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
/// Получить выбранный в данный момент чат
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
let filtered = self.get_filtered_chats();
self.list_state
.selected()
.and_then(|i| filtered.get(i).copied())
}
}

View File

@@ -0,0 +1,160 @@
// Chat state management - type-safe state machine for chat modes
use crate::tdlib::{MessageInfo, ProfileInfo};
use crate::types::MessageId;
/// Vim-like input mode for chat view
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
/// Normal mode — navigation and commands (default)
#[default]
Normal,
/// Insert mode — text input only
Insert,
}
/// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone, Default)]
pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста
#[default]
Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
MessageSelection {
/// Индекс выбранного сообщения (снизу вверх, 0 = последнее)
selected_index: usize,
},
/// Редактирование сообщения
Editing {
/// ID редактируемого сообщения
message_id: MessageId,
/// Индекс сообщения в списке
selected_index: usize,
},
/// Ответ на сообщение (reply)
Reply {
/// ID сообщения, на которое отвечаем
message_id: MessageId,
},
/// Пересылка сообщения (forward)
Forward {
/// ID сообщения для пересылки
message_id: MessageId,
},
/// Подтверждение удаления сообщения
DeleteConfirmation {
/// ID сообщения для удаления
message_id: MessageId,
},
/// Выбор реакции на сообщение
ReactionPicker {
/// ID сообщения для реакции
message_id: MessageId,
/// Список доступных реакций
available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
selected_index: usize,
},
/// Просмотр профиля пользователя/чата
Profile {
/// Информация профиля
info: ProfileInfo,
/// Индекс выбранного действия
selected_action: usize,
/// Шаг подтверждения выхода из группы (0 = не показано, 1-2 = подтверждения)
leave_group_confirmation_step: u8,
},
/// Поиск по сообщениям в текущем чате
SearchInChat {
/// Поисковый запрос
query: String,
/// Результаты поиска
results: Vec<MessageInfo>,
/// Индекс выбранного результата
selected_index: usize,
},
/// Просмотр закреплённых сообщений
PinnedMessages {
/// Список закреплённых сообщений
messages: Vec<MessageInfo>,
/// Индекс выбранного pinned сообщения
selected_index: usize,
},
}
impl ChatState {
/// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool {
matches!(self, ChatState::MessageSelection { .. })
}
/// Проверка: находимся в режиме редактирования
pub fn is_editing(&self) -> bool {
matches!(self, ChatState::Editing { .. })
}
/// Проверка: находимся в режиме ответа
pub fn is_reply(&self) -> bool {
matches!(self, ChatState::Reply { .. })
}
/// Проверка: находимся в режиме пересылки
pub fn is_forward(&self) -> bool {
matches!(self, ChatState::Forward { .. })
}
/// Проверка: показываем подтверждение удаления
pub fn is_delete_confirmation(&self) -> bool {
matches!(self, ChatState::DeleteConfirmation { .. })
}
/// Проверка: показываем reaction picker
pub fn is_reaction_picker(&self) -> bool {
matches!(self, ChatState::ReactionPicker { .. })
}
/// Проверка: показываем профиль
pub fn is_profile(&self) -> bool {
matches!(self, ChatState::Profile { .. })
}
/// Проверка: находимся в режиме поиска по сообщениям
pub fn is_search_in_chat(&self) -> bool {
matches!(self, ChatState::SearchInChat { .. })
}
/// Проверка: показываем pinned сообщения
pub fn is_pinned_mode(&self) -> bool {
matches!(self, ChatState::PinnedMessages { .. })
}
/// Возвращает ID выбранного сообщения (если есть)
pub fn selected_message_id(&self) -> Option<MessageId> {
match self {
ChatState::Editing { message_id, .. } => Some(*message_id),
ChatState::Reply { message_id } => Some(*message_id),
ChatState::Forward { message_id, .. } => Some(*message_id),
ChatState::DeleteConfirmation { message_id } => Some(*message_id),
ChatState::ReactionPicker { message_id, .. } => Some(*message_id),
_ => None,
}
}
/// Возвращает индекс выбранного сообщения (если есть)
pub fn selected_message_index(&self) -> Option<usize> {
match self {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
ChatState::Editing { selected_index, .. } => Some(*selected_index),
_ => None,
}
}
}

View File

@@ -0,0 +1,247 @@
/// Состояние написания сообщения
///
/// Отвечает за:
/// - Текст сообщения
/// - Позицию курсора
/// - Typing indicator
use std::time::Instant;
/// Состояние написания сообщения
#[derive(Debug, Clone)]
pub struct ComposeState {
/// Текст вводимого сообщения
pub message_input: String,
/// Позиция курсора в message_input (в символах, не байтах)
pub cursor_position: usize,
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<Instant>,
}
impl Default for ComposeState {
fn default() -> Self {
Self {
message_input: String::new(),
cursor_position: 0,
last_typing_sent: None,
}
}
}
impl ComposeState {
/// Создать новое состояние написания сообщения
pub fn new() -> Self {
Self::default()
}
// === Message input ===
pub fn message_input(&self) -> &str {
&self.message_input
}
pub fn message_input_mut(&mut self) -> &mut String {
&mut self.message_input
}
pub fn set_message_input(&mut self, input: String) {
self.message_input = input;
self.cursor_position = self.message_input.chars().count();
}
pub fn clear_message_input(&mut self) {
self.message_input.clear();
self.cursor_position = 0;
}
pub fn is_empty(&self) -> bool {
self.message_input.is_empty()
}
// === Cursor position ===
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
pub fn set_cursor_position(&mut self, pos: usize) {
let max_pos = self.message_input.chars().count();
self.cursor_position = pos.min(max_pos);
}
pub fn move_cursor_left(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
pub fn move_cursor_right(&mut self) {
let max_pos = self.message_input.chars().count();
if self.cursor_position < max_pos {
self.cursor_position += 1;
}
}
pub fn move_cursor_to_start(&mut self) {
self.cursor_position = 0;
}
pub fn move_cursor_to_end(&mut self) {
self.cursor_position = self.message_input.chars().count();
}
// === Typing indicator ===
pub fn last_typing_sent(&self) -> Option<Instant> {
self.last_typing_sent
}
pub fn set_last_typing_sent(&mut self, time: Option<Instant>) {
self.last_typing_sent = time;
}
pub fn update_last_typing_sent(&mut self) {
self.last_typing_sent = Some(Instant::now());
}
pub fn clear_typing_indicator(&mut self) {
self.last_typing_sent = None;
}
/// Проверить, нужно ли отправить typing indicator
/// (если прошло больше 5 секунд с последней отправки)
pub fn should_send_typing(&self) -> bool {
match self.last_typing_sent {
None => true,
Some(last) => last.elapsed().as_secs() >= 5,
}
}
// === Text editing ===
/// Вставить символ в текущую позицию курсора
pub fn insert_char(&mut self, c: char) {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
let byte_pos = if self.cursor_position >= char_indices.len() {
self.message_input.len()
} else {
char_indices[self.cursor_position]
};
self.message_input.insert(byte_pos, c);
self.cursor_position += 1;
}
/// Удалить символ перед курсором (Backspace)
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_position > 0 {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
let byte_pos = char_indices[self.cursor_position - 1];
self.message_input.remove(byte_pos);
self.cursor_position -= 1;
}
}
/// Удалить символ после курсора (Delete)
pub fn delete_char_after_cursor(&mut self) {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
if self.cursor_position < char_indices.len() {
let byte_pos = char_indices[self.cursor_position];
self.message_input.remove(byte_pos);
}
}
/// Удалить слово перед курсором (Ctrl+Backspace)
pub fn delete_word_before_cursor(&mut self) {
if self.cursor_position == 0 {
return;
}
let chars: Vec<char> = self.message_input.chars().collect();
let mut pos = self.cursor_position;
// Пропустить пробелы
while pos > 0 && chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Удалить символы слова
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
let removed_count = self.cursor_position - pos;
if removed_count > 0 {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
let start_byte = char_indices[pos];
let end_byte = if self.cursor_position >= char_indices.len() {
self.message_input.len()
} else {
char_indices[self.cursor_position]
};
self.message_input.drain(start_byte..end_byte);
self.cursor_position = pos;
}
}
/// Очистить всё и сбросить состояние
pub fn reset(&mut self) {
self.message_input.clear();
self.cursor_position = 0;
self.last_typing_sent = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_char() {
let mut state = ComposeState::new();
state.insert_char('H');
state.insert_char('i');
assert_eq!(state.message_input(), "Hi");
assert_eq!(state.cursor_position(), 2);
}
#[test]
fn test_delete_char_before_cursor() {
let mut state = ComposeState::new();
state.set_message_input("Hello".to_string());
state.delete_char_before_cursor();
assert_eq!(state.message_input(), "Hell");
assert_eq!(state.cursor_position(), 4);
}
#[test]
fn test_cursor_movement() {
let mut state = ComposeState::new();
state.set_message_input("Hello".to_string());
state.move_cursor_to_start();
assert_eq!(state.cursor_position(), 0);
state.move_cursor_right();
assert_eq!(state.cursor_position(), 1);
state.move_cursor_to_end();
assert_eq!(state.cursor_position(), 5);
state.move_cursor_left();
assert_eq!(state.cursor_position(), 4);
}
#[test]
fn test_delete_word() {
let mut state = ComposeState::new();
state.set_message_input("Hello World".to_string());
state.delete_word_before_cursor();
assert_eq!(state.message_input(), "Hello ");
}
}

View File

@@ -0,0 +1,512 @@
/// Модуль для бизнес-логики работы с сообщениями
///
/// Чёткое разделение ответственности:
/// - `tdlib/messages.rs` - только получение и преобразование из TDLib
/// - `app/message_service.rs` (этот модуль) - бизнес-логика и операции
/// - `ui/messages.rs` - только рендеринг
///
/// Этот модуль отвечает за:
/// - Группировку сообщений по дате и отправителю
/// - Фильтрацию сообщений
/// - Поиск внутри сообщений
/// - Навигацию по сообщениям
/// - Операции над сообщениями (edit, delete, reply и т.д.)
use crate::tdlib::MessageInfo;
use crate::types::MessageId;
use chrono::{DateTime, Local};
use std::collections::HashMap;
/// Группа сообщений по дате
#[derive(Debug, Clone)]
pub struct MessageGroup {
/// Дата группы (отображаемая строка, например "Сегодня", "Вчера", "1 января")
pub date_label: String,
/// Сообщения в этой группе (отсортированы по времени)
pub messages: Vec<MessageId>,
}
/// Подгруппа сообщений от одного отправителя
#[derive(Debug, Clone)]
pub struct SenderGroup {
/// ID первого сообщения в группе
pub first_message_id: MessageId,
/// Имя отправителя
pub sender_name: String,
/// Список ID сообщений от этого отправителя подряд
pub message_ids: Vec<MessageId>,
}
/// Результат поиска сообщений
#[derive(Debug, Clone)]
pub struct MessageSearchResult {
/// ID сообщения
pub message_id: MessageId,
/// Позиция в списке сообщений
pub index: usize,
/// Фрагмент текста с совпадением
pub snippet: String,
/// Позиция совпадения в тексте
pub match_position: usize,
}
/// Сервис для работы с сообщениями
pub struct MessageService;
impl MessageService {
/// Группирует сообщения по дате
///
/// # Arguments
///
/// * `messages` - Список сообщений (должен быть отсортирован по времени)
/// * `timezone_offset` - Смещение часового пояса в секундах
///
/// # Returns
///
/// Список групп сообщений по датам
pub fn group_by_date(
messages: &[MessageInfo],
timezone_offset: i32,
) -> Vec<MessageGroup> {
let mut groups: Vec<MessageGroup> = Vec::new();
let mut current_date: Option<String> = None;
let mut current_messages: Vec<MessageId> = Vec::new();
for msg in messages {
let date_label = Self::get_date_label(msg.date(), timezone_offset);
if current_date.as_ref() != Some(&date_label) {
// Начинается новая дата - сохраняем предыдущую группу
if let Some(date) = current_date {
groups.push(MessageGroup {
date_label: date,
messages: current_messages.clone(),
});
current_messages.clear();
}
current_date = Some(date_label);
}
current_messages.push(msg.id());
}
// Добавляем последнюю группу
if let Some(date) = current_date {
groups.push(MessageGroup {
date_label: date,
messages: current_messages,
});
}
groups
}
/// Группирует сообщения по отправителю внутри одной даты
///
/// Последовательные сообщения от одного отправителя объединяются в группу.
pub fn group_by_sender(messages: &[MessageInfo]) -> Vec<SenderGroup> {
let mut groups: Vec<SenderGroup> = Vec::new();
let mut current_sender: Option<String> = None;
let mut current_ids: Vec<MessageId> = Vec::new();
let mut first_id: Option<MessageId> = None;
for msg in messages {
let sender = msg.sender_name().to_string();
if current_sender.as_ref() != Some(&sender) {
// Новый отправитель - сохраняем предыдущую группу
if let (Some(name), Some(first)) = (current_sender, first_id) {
groups.push(SenderGroup {
first_message_id: first,
sender_name: name,
message_ids: current_ids.clone(),
});
current_ids.clear();
}
current_sender = Some(sender);
first_id = Some(msg.id());
}
current_ids.push(msg.id());
}
// Добавляем последнюю группу
if let (Some(name), Some(first)) = (current_sender, first_id) {
groups.push(SenderGroup {
first_message_id: first,
sender_name: name,
message_ids: current_ids,
});
}
groups
}
/// Получает человекочитаемую метку даты
///
/// Возвращает "Сегодня", "Вчера" или дату в формате "1 января 2024"
fn get_date_label(timestamp: i32, _timezone_offset: i32) -> String {
let dt = DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local))
.unwrap_or_else(|| Local::now());
let msg_date = dt.date_naive();
let today = Local::now().date_naive();
let yesterday = today.pred_opt().unwrap_or(today);
if msg_date == today {
"Сегодня".to_string()
} else if msg_date == yesterday {
"Вчера".to_string()
} else {
msg_date.format("%d %B %Y").to_string()
}
}
/// Ищет сообщения по текстовому запросу
///
/// # Arguments
///
/// * `messages` - Список сообщений для поиска
/// * `query` - Поисковый запрос (case-insensitive)
/// * `max_results` - Максимальное количество результатов (0 = без ограничений)
///
/// # Returns
///
/// Список результатов поиска с контекстом
pub fn search(
messages: &[MessageInfo],
query: &str,
max_results: usize,
) -> Vec<MessageSearchResult> {
if query.is_empty() {
return Vec::new();
}
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for (index, msg) in messages.iter().enumerate() {
let text = msg.text().to_lowercase();
if let Some(pos) = text.find(&query_lower) {
// Создаём snippet с контекстом
let start = pos.saturating_sub(20);
let end = (pos + query.len() + 20).min(text.len());
let snippet = msg.text()[start..end].to_string();
results.push(MessageSearchResult {
message_id: msg.id(),
index,
snippet,
match_position: pos,
});
if max_results > 0 && results.len() >= max_results {
break;
}
}
}
results
}
/// Находит следующее сообщение по запросу
///
/// # Arguments
///
/// * `messages` - Список сообщений
/// * `current_index` - Текущая позиция
/// * `query` - Поисковый запрос
///
/// # Returns
///
/// Индекс следующего найденного сообщения или None
pub fn find_next(
messages: &[MessageInfo],
current_index: usize,
query: &str,
) -> Option<usize> {
if query.is_empty() {
return None;
}
let query_lower = query.to_lowercase();
for (index, msg) in messages.iter().enumerate().skip(current_index + 1) {
if msg.text().to_lowercase().contains(&query_lower) {
return Some(index);
}
}
None
}
/// Находит предыдущее сообщение по запросу
pub fn find_previous(
messages: &[MessageInfo],
current_index: usize,
query: &str,
) -> Option<usize> {
if query.is_empty() || current_index == 0 {
return None;
}
let query_lower = query.to_lowercase();
for (index, msg) in messages.iter().enumerate().take(current_index).rev() {
if msg.text().to_lowercase().contains(&query_lower) {
return Some(index);
}
}
None
}
/// Фильтрует сообщения по отправителю
pub fn filter_by_sender<'a>(
messages: &'a [MessageInfo],
sender_name: &str,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| msg.sender_name() == sender_name)
.collect()
}
/// Фильтрует только непрочитанные сообщения
pub fn filter_unread<'a>(
messages: &'a [MessageInfo],
last_read_id: MessageId,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| msg.id().as_i64() > last_read_id.as_i64())
.collect()
}
/// Находит сообщение по ID
pub fn find_by_id<'a>(
messages: &'a [MessageInfo],
id: MessageId,
) -> Option<&'a MessageInfo> {
messages.iter().find(|msg| msg.id() == id)
}
/// Находит индекс сообщения по ID
pub fn find_index_by_id(
messages: &[MessageInfo],
id: MessageId,
) -> Option<usize> {
messages.iter().position(|msg| msg.id() == id)
}
/// Получает N последних сообщений
pub fn get_last_n<'a>(
messages: &'a [MessageInfo],
n: usize,
) -> &'a [MessageInfo] {
let start = messages.len().saturating_sub(n);
&messages[start..]
}
/// Получает сообщения в диапазоне дат
pub fn get_in_date_range<'a>(
messages: &'a [MessageInfo],
start_date: i32,
end_date: i32,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| {
let date = msg.date();
date >= start_date && date <= end_date
})
.collect()
}
/// Подсчитывает сообщения по типу отправителя
pub fn count_by_sender_type(messages: &[MessageInfo]) -> (usize, usize) {
let mut incoming = 0;
let mut outgoing = 0;
for msg in messages {
if msg.is_outgoing() {
outgoing += 1;
} else {
incoming += 1;
}
}
(incoming, outgoing)
}
/// Создаёт индекс сообщений по ID для быстрого доступа
pub fn create_index(messages: &[MessageInfo]) -> HashMap<MessageId, usize> {
messages
.iter()
.enumerate()
.map(|(index, msg)| (msg.id(), index))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tdlib::MessageInfo;
use crate::types::MessageId;
fn create_test_message(
id: i64,
text: &str,
sender: &str,
date: i32,
is_outgoing: bool,
) -> MessageInfo {
MessageInfo::new(
MessageId::new(id),
sender.to_string(),
is_outgoing,
text.to_string(),
Vec::new(), // entities
date,
0, // edit_date
true, // is_read
is_outgoing, // can_be_edited only for outgoing
true, // can_be_deleted_only_for_self
is_outgoing, // can_be_deleted_for_all_users only for outgoing
None, // reply_to
None, // forward_from
Vec::new(), // reactions
)
}
#[test]
fn test_search() {
let messages = vec![
create_test_message(1, "Hello world", "Alice", 1000, false),
create_test_message(2, "How are you?", "Bob", 1010, false),
create_test_message(3, "Hello there", "Alice", 1020, false),
];
let results = MessageService::search(&messages, "hello", 0);
assert_eq!(results.len(), 2);
assert_eq!(results[0].message_id.as_i64(), 1);
assert_eq!(results[1].message_id.as_i64(), 3);
// Case-insensitive
let results = MessageService::search(&messages, "HELLO", 0);
assert_eq!(results.len(), 2);
// Max results
let results = MessageService::search(&messages, "hello", 1);
assert_eq!(results.len(), 1);
}
#[test]
fn test_find_next_previous() {
let messages = vec![
create_test_message(1, "test 1", "Alice", 1000, false),
create_test_message(2, "message", "Bob", 1010, false),
create_test_message(3, "test 2", "Alice", 1020, false),
create_test_message(4, "test 3", "Bob", 1030, false),
];
// Find next
let next = MessageService::find_next(&messages, 0, "test");
assert_eq!(next, Some(2));
let next = MessageService::find_next(&messages, 2, "test");
assert_eq!(next, Some(3));
// Find previous
let prev = MessageService::find_previous(&messages, 3, "test");
assert_eq!(prev, Some(2));
let prev = MessageService::find_previous(&messages, 2, "test");
assert_eq!(prev, Some(0));
}
#[test]
fn test_filter_by_sender() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let filtered = MessageService::filter_by_sender(&messages, "Alice");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].id().as_i64(), 1);
assert_eq!(filtered[1].id().as_i64(), 3);
}
#[test]
fn test_find_by_id() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
];
let found = MessageService::find_by_id(&messages, MessageId::new(2));
assert!(found.is_some());
assert_eq!(found.unwrap().text(), "msg2");
let not_found = MessageService::find_by_id(&messages, MessageId::new(999));
assert!(not_found.is_none());
}
#[test]
fn test_count_by_sender_type() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Me", 1010, true),
create_test_message(3, "msg3", "Bob", 1020, false),
create_test_message(4, "msg4", "Me", 1030, true),
];
let (incoming, outgoing) = MessageService::count_by_sender_type(&messages);
assert_eq!(incoming, 2);
assert_eq!(outgoing, 2);
}
#[test]
fn test_get_last_n() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let last_2 = MessageService::get_last_n(&messages, 2);
assert_eq!(last_2.len(), 2);
assert_eq!(last_2[0].id().as_i64(), 2);
assert_eq!(last_2[1].id().as_i64(), 3);
// Request more than available
let last_10 = MessageService::get_last_n(&messages, 10);
assert_eq!(last_10.len(), 3);
}
#[test]
fn test_create_index() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let index = MessageService::create_index(&messages);
assert_eq!(index.len(), 3);
assert_eq!(index.get(&MessageId::new(1)), Some(&0));
assert_eq!(index.get(&MessageId::new(2)), Some(&1));
assert_eq!(index.get(&MessageId::new(3)), Some(&2));
}
}

View File

@@ -0,0 +1,277 @@
/// Состояние просмотра сообщений
///
/// Отвечает за:
/// - Текущий открытый чат
/// - Скроллинг сообщений
/// - Состояние чата (редактирование, ответ, и т.д.)
use crate::app::ChatState;
use crate::types::{ChatId, MessageId};
/// Состояние просмотра сообщений
#[derive(Debug, Clone)]
pub struct MessageViewState {
/// ID текущего открытого чата
pub selected_chat_id: Option<ChatId>,
/// Оффсет скроллинга для сообщений
pub message_scroll_offset: usize,
/// Состояние чата (Normal, Editing, Reply, и т.д.)
pub chat_state: ChatState,
}
impl Default for MessageViewState {
fn default() -> Self {
Self {
selected_chat_id: None,
message_scroll_offset: 0,
chat_state: ChatState::Normal,
}
}
}
impl MessageViewState {
/// Создать новое состояние просмотра сообщений
pub fn new() -> Self {
Self::default()
}
// === Selected chat ===
pub fn selected_chat_id(&self) -> Option<ChatId> {
self.selected_chat_id
}
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
self.selected_chat_id = id;
}
pub fn has_open_chat(&self) -> bool {
self.selected_chat_id.is_some()
}
pub fn close_chat(&mut self) {
self.selected_chat_id = None;
self.message_scroll_offset = 0;
self.chat_state = ChatState::Normal;
}
// === Scroll offset ===
pub fn message_scroll_offset(&self) -> usize {
self.message_scroll_offset
}
pub fn set_message_scroll_offset(&mut self, offset: usize) {
self.message_scroll_offset = offset;
}
pub fn reset_scroll(&mut self) {
self.message_scroll_offset = 0;
}
// === Chat state ===
pub fn chat_state(&self) -> &ChatState {
&self.chat_state
}
pub fn chat_state_mut(&mut self) -> &mut ChatState {
&mut self.chat_state
}
pub fn set_chat_state(&mut self, state: ChatState) {
self.chat_state = state;
}
pub fn reset_chat_state(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Message selection ===
pub fn is_selecting_message(&self) -> bool {
self.chat_state.is_message_selection()
}
pub fn start_message_selection(&mut self, total_messages: usize) {
if total_messages == 0 {
return;
}
self.chat_state = ChatState::MessageSelection {
selected_index: total_messages - 1,
};
}
pub fn select_previous_message(&mut self) {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
pub fn select_next_message(&mut self, total_messages: usize) {
if total_messages == 0 {
return;
}
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total_messages - 1 {
*selected_index += 1;
} else {
self.chat_state = ChatState::Normal;
}
}
}
pub fn get_selected_message_index(&self) -> Option<usize> {
self.chat_state.selected_message_index()
}
// === Editing ===
pub fn is_editing(&self) -> bool {
self.chat_state.is_editing()
}
pub fn start_editing(&mut self, message_id: MessageId, selected_index: usize) {
self.chat_state = ChatState::Editing {
message_id,
selected_index,
};
}
pub fn cancel_editing(&mut self) {
self.chat_state = ChatState::Normal;
}
pub fn get_editing_message_id(&self) -> Option<MessageId> {
if let ChatState::Editing { message_id, .. } = &self.chat_state {
Some(*message_id)
} else {
None
}
}
// === Reply ===
pub fn is_replying(&self) -> bool {
self.chat_state.is_reply()
}
pub fn start_reply(&mut self, message_id: MessageId) {
self.chat_state = ChatState::Reply { message_id };
}
pub fn cancel_reply(&mut self) {
self.chat_state = ChatState::Normal;
}
pub fn get_replying_to_message_id(&self) -> Option<MessageId> {
if let ChatState::Reply { message_id } = &self.chat_state {
Some(*message_id)
} else {
None
}
}
// === Forward ===
pub fn is_forwarding(&self) -> bool {
self.chat_state.is_forward()
}
pub fn start_forward(&mut self, message_id: MessageId) {
self.chat_state = ChatState::Forward {
message_id,
};
}
pub fn cancel_forward(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Delete confirmation ===
pub fn is_confirm_delete_shown(&self) -> bool {
self.chat_state.is_delete_confirmation()
}
// === Pinned messages ===
pub fn is_pinned_mode(&self) -> bool {
self.chat_state.is_pinned_mode()
}
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages {
messages,
selected_index: 0,
};
}
}
pub fn exit_pinned_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Search in chat ===
pub fn is_message_search_mode(&self) -> bool {
self.chat_state.is_search_in_chat()
}
pub fn enter_message_search_mode(&mut self) {
self.chat_state = ChatState::SearchInChat {
query: String::new(),
results: Vec::new(),
selected_index: 0,
};
}
pub fn exit_message_search_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Profile ===
pub fn is_profile_mode(&self) -> bool {
self.chat_state.is_profile()
}
pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) {
self.chat_state = ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
};
}
pub fn exit_profile_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Reaction picker ===
pub fn is_reaction_picker_mode(&self) -> bool {
self.chat_state.is_reaction_picker()
}
pub fn enter_reaction_picker_mode(
&mut self,
message_id: MessageId,
available_reactions: Vec<String>,
) {
self.chat_state = ChatState::ReactionPicker {
message_id,
available_reactions,
selected_index: 0,
};
}
pub fn exit_reaction_picker_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
}

View File

@@ -0,0 +1,117 @@
//! Compose methods for App
//!
//! Handles reply, forward, and draft functionality
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft
pub trait ComposeMethods<T: TdClientTrait> {
/// Start replying to the selected message
/// Returns true if reply mode started, false if no message selected
fn start_reply_to_selected(&mut self) -> bool;
/// Cancel reply mode
fn cancel_reply(&mut self);
/// Check if currently in reply mode
fn is_replying(&self) -> bool;
/// Get the message being replied to
fn get_replying_to_message(&self) -> Option<MessageInfo>;
/// Start forwarding the selected message
/// Returns true if forward mode started, false if no message selected
fn start_forward_selected(&mut self) -> bool;
/// Cancel forward mode
fn cancel_forward(&mut self);
/// Check if currently in forward mode (selecting target chat)
fn is_forwarding(&self) -> bool;
/// Get the message being forwarded
fn get_forwarding_message(&self) -> Option<MessageInfo>;
/// Get draft for the currently selected chat
fn get_current_draft(&self) -> Option<String>;
/// Load draft into message_input (called when opening chat)
fn load_draft(&mut self);
}
impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Reply { message_id: msg.id() };
return true;
}
false
}
fn cancel_reply(&mut self) {
self.chat_state = ChatState::Normal;
}
fn is_replying(&self) -> bool {
self.chat_state.is_reply()
}
fn get_replying_to_message(&self) -> Option<MessageInfo> {
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Forward { message_id: msg.id() };
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;
}
false
}
fn cancel_forward(&mut self) {
self.chat_state = ChatState::Normal;
}
fn is_forwarding(&self) -> bool {
self.chat_state.is_forward()
}
fn get_forwarding_message(&self) -> Option<MessageInfo> {
if !self.chat_state.is_forward() {
return None;
}
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
fn get_current_draft(&self) -> Option<String> {
self.selected_chat_id.and_then(|chat_id| {
self.chats
.iter()
.find(|c| c.id == chat_id)
.and_then(|c| c.draft_text.clone())
})
}
fn load_draft(&mut self) {
if let Some(draft) = self.get_current_draft() {
self.message_input = draft;
self.cursor_position = self.message_input.chars().count();
}
}
}

View File

@@ -0,0 +1,175 @@
//! Message methods for App
//!
//! Handles message selection, editing, and operations
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Message operation methods
pub trait MessageMethods<T: TdClientTrait> {
/// Start message selection mode (triggered by Up arrow in empty input)
fn start_message_selection(&mut self);
/// Select previous message (up in history = older)
fn select_previous_message(&mut self);
/// Select next message (down in history = newer)
fn select_next_message(&mut self);
/// Get currently selected message
fn get_selected_message(&self) -> Option<MessageInfo>;
/// Start editing the selected message
/// Returns true if editing started, false if message cannot be edited
fn start_editing_selected(&mut self) -> bool;
/// Cancel message editing and clear input
fn cancel_editing(&mut self);
/// Check if currently in editing mode
fn is_editing(&self) -> bool;
/// Check if currently in message selection mode
fn is_selecting_message(&self) -> bool;
}
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
fn start_message_selection(&mut self) {
let messages = self.td_client.current_chat_messages();
let total = messages.len();
if total == 0 {
return;
}
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
// Если оно часть альбома — перемещаемся к первому элементу альбома
let mut idx = total - 1;
let album_id = messages[idx].media_album_id();
if album_id != 0 {
while idx > 0 && messages[idx - 1].media_album_id() == album_id {
idx -= 1;
}
}
self.chat_state = ChatState::MessageSelection { selected_index: idx };
}
fn select_previous_message(&mut self) {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 {
let messages = self.td_client.current_chat_messages();
let current_album_id = messages[*selected_index].media_album_id();
// Перескакиваем через все сообщения текущего альбома назад
let mut new_index = *selected_index - 1;
if current_album_id != 0 {
while new_index > 0 && messages[new_index].media_album_id() == current_album_id
{
new_index -= 1;
}
}
// Если попали в середину другого альбома — перемещаемся к его первому элементу
let target_album_id = messages[new_index].media_album_id();
if target_album_id != 0 {
while new_index > 0
&& messages[new_index - 1].media_album_id() == target_album_id
{
new_index -= 1;
}
}
*selected_index = new_index;
self.stop_playback();
}
}
}
fn select_next_message(&mut self) {
let total = self.td_client.current_chat_messages().len();
if total == 0 {
return;
}
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total - 1 {
let messages = self.td_client.current_chat_messages();
let current_album_id = messages[*selected_index].media_album_id();
// Перескакиваем через все сообщения текущего альбома вперёд
let mut new_index = *selected_index + 1;
if current_album_id != 0 {
while new_index < total - 1
&& messages[new_index].media_album_id() == current_album_id
{
new_index += 1;
}
// Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее
if messages[new_index].media_album_id() == current_album_id
&& new_index < total - 1
{
new_index += 1;
}
}
if new_index < total {
*selected_index = new_index;
self.stop_playback();
}
// Если new_index >= total — остаёмся на текущем
}
// Если уже на последнем — ничего не делаем, остаёмся на месте
}
}
fn get_selected_message(&self) -> Option<MessageInfo> {
self.chat_state
.selected_message_index()
.and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned())
}
fn start_editing_selected(&mut self) -> bool {
// Получаем selected_index из текущего состояния
let selected_idx = match &self.chat_state {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
_ => None,
};
let Some(selected_idx) = selected_idx else {
return false;
};
// Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| {
// Проверяем:
// 1. Можно редактировать
// 2. Это исходящее сообщение
// 3. ID не временный (временные ID в TDLib отрицательные)
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
Some((msg.id(), msg.text().to_string(), selected_idx))
} else {
None
}
});
// Затем присваиваем
if let Some((id, content, idx)) = msg_data {
self.cursor_position = content.chars().count();
self.message_input = content;
self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
return true;
}
false
}
fn cancel_editing(&mut self) {
self.chat_state = ChatState::Normal;
self.message_input.clear();
self.cursor_position = 0;
}
fn is_editing(&self) -> bool {
self.chat_state.is_editing()
}
fn is_selecting_message(&self) -> bool {
self.chat_state.is_message_selection()
}
}

View File

@@ -0,0 +1,25 @@
//! App methods organized by functionality
//!
//! This module contains traits that organize App methods into logical groups:
//! - navigation: Chat list navigation
//! - messages: Message operations and selection
//! - compose: Reply/Forward/Draft functionality
//! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod compose;
pub mod messages;
pub mod modal;
pub mod navigation;
pub mod search;
#[allow(unused_imports)]
pub use compose::ComposeMethods;
#[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods;
#[allow(unused_imports)]
pub use navigation::NavigationMethods;
#[allow(unused_imports)]
pub use search::SearchMethods;

View File

@@ -0,0 +1,266 @@
//! Modal methods for App
//!
//! Handles modal dialogs: Profile, Pinned Messages, Reactions, Delete Confirmation
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, ProfileInfo, TdClientTrait};
use crate::types::MessageId;
/// Modal dialog methods
pub trait ModalMethods<T: TdClientTrait> {
// === Delete Confirmation ===
/// Check if delete confirmation modal is shown
fn is_confirm_delete_shown(&self) -> bool;
// === Pinned Messages ===
/// Check if in pinned messages mode
fn is_pinned_mode(&self) -> bool;
/// Enter pinned messages mode
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>);
/// Exit pinned messages mode
fn exit_pinned_mode(&mut self);
/// Select previous pinned message (up = older)
fn select_previous_pinned(&mut self);
/// Select next pinned message (down = newer)
fn select_next_pinned(&mut self);
/// Get currently selected pinned message
fn get_selected_pinned(&self) -> Option<&MessageInfo>;
/// Get ID of selected pinned message for navigation
fn get_selected_pinned_id(&self) -> Option<i64>;
// === Profile ===
/// Check if in profile mode
fn is_profile_mode(&self) -> bool;
/// Enter profile mode
fn enter_profile_mode(&mut self, info: ProfileInfo);
/// Exit profile mode
fn exit_profile_mode(&mut self);
/// Select previous profile action
fn select_previous_profile_action(&mut self);
/// Select next profile action
fn select_next_profile_action(&mut self, max_actions: usize);
/// Show first leave group confirmation
fn show_leave_group_confirmation(&mut self);
/// Show second leave group confirmation
fn show_leave_group_final_confirmation(&mut self);
/// Cancel leave group confirmation
fn cancel_leave_group(&mut self);
/// Get current leave group confirmation step (0, 1, or 2)
fn get_leave_group_confirmation_step(&self) -> u8;
/// Get profile info
fn get_profile_info(&self) -> Option<&ProfileInfo>;
/// Get selected profile action index
fn get_selected_profile_action(&self) -> Option<usize>;
// === Reactions ===
/// Check if in reaction picker mode
fn is_reaction_picker_mode(&self) -> bool;
/// Enter reaction picker mode
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>);
/// Exit reaction picker mode
fn exit_reaction_picker_mode(&mut self);
/// Select previous reaction
fn select_previous_reaction(&mut self);
/// Select next reaction
fn select_next_reaction(&mut self);
/// Get currently selected reaction emoji
fn get_selected_reaction(&self) -> Option<&String>;
/// Get message ID for which reaction is being selected
fn get_selected_message_for_reaction(&self) -> Option<i64>;
}
impl<T: TdClientTrait> ModalMethods<T> for App<T> {
fn is_confirm_delete_shown(&self) -> bool {
self.chat_state.is_delete_confirmation()
}
fn is_pinned_mode(&self) -> bool {
self.chat_state.is_pinned_mode()
}
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
}
}
fn exit_pinned_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
if *selected_index + 1 < messages.len() {
*selected_index += 1;
}
}
}
fn select_next_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages.get(*selected_index)
} else {
None
}
}
fn get_selected_pinned_id(&self) -> Option<i64> {
self.get_selected_pinned().map(|m| m.id().as_i64())
}
fn is_profile_mode(&self) -> bool {
self.chat_state.is_profile()
}
fn enter_profile_mode(&mut self, info: ProfileInfo) {
self.chat_state = ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
};
}
fn exit_profile_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_profile_action(&mut self) {
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action > 0 {
*selected_action -= 1;
}
}
}
fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1;
}
}
}
fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 1;
}
}
fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 2;
}
}
fn cancel_leave_group(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 0;
}
}
fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
*leave_group_confirmation_step
} else {
0
}
}
fn get_profile_info(&self) -> Option<&ProfileInfo> {
if let ChatState::Profile { info, .. } = &self.chat_state {
Some(info)
} else {
None
}
}
fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile { selected_action, .. } = &self.chat_state {
Some(*selected_action)
} else {
None
}
}
fn is_reaction_picker_mode(&self) -> bool {
self.chat_state.is_reaction_picker()
}
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
self.chat_state = ChatState::ReactionPicker {
message_id: MessageId::new(message_id),
available_reactions,
selected_index: 0,
};
}
fn exit_reaction_picker_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
&mut self.chat_state
{
if *selected_index + 1 < available_reactions.len() {
*selected_index += 1;
}
}
}
fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&self.chat_state
{
available_reactions.get(*selected_index)
} else {
None
}
}
fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.chat_state.selected_message_id().map(|id| id.as_i64())
}
}

View File

@@ -0,0 +1,148 @@
//! Navigation methods for App
//!
//! Handles chat list navigation and selection
use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list
pub trait NavigationMethods<T: TdClientTrait> {
/// Move to next chat in the list (wraps around)
fn next_chat(&mut self);
/// Move to previous chat in the list (wraps around)
fn previous_chat(&mut self);
/// Select currently highlighted chat
fn select_current_chat(&mut self);
/// Close currently open chat and reset state
fn close_chat(&mut self);
/// Move to next filtered chat (considering search query)
fn next_filtered_chat(&mut self);
/// Move to previous filtered chat (considering search query)
fn previous_filtered_chat(&mut self);
/// Select currently highlighted filtered chat
fn select_filtered_chat(&mut self);
}
impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
fn next_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn previous_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_current_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
}
}
}
fn close_chat(&mut self) {
self.selected_chat_id = None;
self.message_input.clear();
self.cursor_position = 0;
self.message_scroll_offset = 0;
self.last_typing_sent = None;
self.pending_chat_init = None;
self.chat_init_rx = None;
// Останавливаем фоновую загрузку фото (drop receiver)
#[cfg(feature = "images")]
{
self.photo_download_rx = None;
self.pending_image_open = None;
}
// Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal;
self.input_mode = InputMode::Normal;
// Очищаем данные в TdClient
self.td_client.set_current_chat_id(None);
self.td_client.clear_current_chat_messages();
self.td_client.set_typing_status(None);
self.td_client.set_current_pinned_message(None);
}
fn next_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn previous_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
self.cancel_search();
}
}
}
}

View File

@@ -0,0 +1,165 @@
//! Search methods for App
//!
//! Handles chat list search and message search within chat
use crate::app::{App, ChatFilter, ChatFilterCriteria, ChatState};
use crate::tdlib::{ChatInfo, MessageInfo, TdClientTrait};
/// Search methods for chats and messages
pub trait SearchMethods<T: TdClientTrait> {
// === Chat Search ===
/// Start search mode in chat list
fn start_search(&mut self);
/// Cancel search mode and reset query
fn cancel_search(&mut self);
/// Get filtered chats based on search query and selected folder
fn get_filtered_chats(&self) -> Vec<&ChatInfo>;
// === Message Search ===
/// Check if message search mode is active
fn is_message_search_mode(&self) -> bool;
/// Enter message search mode within chat
fn enter_message_search_mode(&mut self);
/// Exit message search mode
fn exit_message_search_mode(&mut self);
/// Set search results
fn set_search_results(&mut self, results: Vec<MessageInfo>);
/// Select previous search result (up)
fn select_previous_search_result(&mut self);
/// Select next search result (down)
fn select_next_search_result(&mut self);
/// Get currently selected search result
fn get_selected_search_result(&self) -> Option<&MessageInfo>;
/// Get ID of selected search result for navigation
fn get_selected_search_result_id(&self) -> Option<i64>;
/// Get current search query
fn get_search_query(&self) -> Option<&str>;
/// Update search query
fn update_search_query(&mut self, new_query: String);
/// Get index of selected search result
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results
#[allow(dead_code)]
fn get_search_results(&self) -> Option<&[MessageInfo]>;
}
impl<T: TdClientTrait> SearchMethods<T> for App<T> {
fn start_search(&mut self) {
self.is_searching = true;
self.search_query.clear();
}
fn cancel_search(&mut self) {
self.is_searching = false;
self.search_query.clear();
self.chat_list_state.select(Some(0));
}
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone());
}
ChatFilter::filter(&self.chats, &criteria)
}
fn is_message_search_mode(&self) -> bool {
self.chat_state.is_search_in_chat()
}
fn enter_message_search_mode(&mut self) {
self.chat_state = ChatState::SearchInChat {
query: String::new(),
results: Vec::new(),
selected_index: 0,
};
}
fn exit_message_search_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn set_search_results(&mut self, results: Vec<MessageInfo>) {
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
*r = results;
*selected_index = 0;
}
}
fn select_previous_search_result(&mut self) {
if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
if *selected_index + 1 < results.len() {
*selected_index += 1;
}
}
}
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results.get(*selected_index)
} else {
None
}
}
fn get_selected_search_result_id(&self) -> Option<i64> {
self.get_selected_search_result().map(|m| m.id().as_i64())
}
fn get_search_query(&self) -> Option<&str> {
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
Some(query.as_str())
} else {
None
}
}
fn update_search_query(&mut self, new_query: String) {
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
*query = new_query;
}
}
fn get_search_selected_index(&self) -> Option<usize> {
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
Some(*selected_index)
} else {
None
}
}
fn get_search_results(&self) -> Option<&[MessageInfo]> {
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
Some(results.as_slice())
} else {
None
}
}
}

View File

@@ -0,0 +1,635 @@
//! Application state module.
//!
//! Contains `App<T>` — the central state struct parameterized by `TdClientTrait`
//! for dependency injection. Methods are organized into trait modules in `methods/`.
mod chat_filter;
mod chat_state;
pub mod methods;
mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode};
#[allow(unused_imports)]
pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile;
use crate::notifications::NotificationManager;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::tdlib::{TdClientConfig, TdCredentials};
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
use std::path::PathBuf;
/// Pending intent to open the image modal once a photo finishes downloading.
///
/// Set when the user presses `v` on a photo that is still downloading.
/// The main loop opens the modal automatically when the download completes.
#[cfg(feature = "images")]
#[derive(Debug, Clone)]
pub struct PendingImageOpen {
pub file_id: i32,
pub message_id: crate::types::MessageId,
pub photo_width: i32,
pub photo_height: i32,
}
/// Result from background chat initialization tasks.
#[derive(Debug, Clone)]
pub enum ChatInitEvent {
ReplyInfoLoaded {
chat_id: ChatId,
message_id: MessageId,
sender_name: String,
text: String,
},
}
/// State of the account switcher modal overlay.
#[derive(Debug, Clone)]
pub enum AccountSwitcherState {
/// List of accounts with navigation.
SelectAccount {
accounts: Vec<AccountProfile>,
selected_index: usize,
current_account: String,
},
/// Input for new account name.
AddAccount {
name_input: String,
cursor_position: usize,
error: Option<String>,
},
}
/// Main application state for the Telegram TUI client.
///
/// Manages all application state including authentication, chats, messages,
/// and UI state. Integrates with TDLib через `TdClient` and handles user input.
///
/// # State Machine
///
/// The app uses a type-safe state machine (`ChatState`) for chat-related operations:
/// - `Normal` - default state
/// - `MessageSelection` - selecting a message
/// - `Editing` - editing a message
/// - `Reply` - replying to a message
/// - `Forward` - forwarding a message
/// - `DeleteConfirmation` - confirming deletion
/// - `ReactionPicker` - choosing a reaction
/// - `Profile` - viewing profile
/// - `SearchInChat` - searching within chat
/// - `PinnedMessages` - viewing pinned messages
///
/// # Examples
///
/// ```no_run
/// use tele_tui::app::App;
/// use tele_tui::app::methods::navigation::NavigationMethods;
/// use tele_tui::config::Config;
///
/// let config = Config::default();
/// let mut app = App::new(config, std::path::PathBuf::from("tdlib_data"));
///
/// // Navigate through chats
/// app.next_chat();
/// app.previous_chat();
///
/// // Open a chat
/// app.select_current_chat();
/// ```
pub struct App<T: TdClientTrait = TdClient> {
// Core (config - readonly через getter)
config: crate::config::Config,
pub screen: AppScreen,
pub td_client: T,
pub notification_manager: NotificationManager,
/// Состояние чата - type-safe state machine (новое!)
pub chat_state: ChatState,
/// Vim-like input mode: Normal (navigation) / Insert (text input)
pub input_mode: InputMode,
// Auth state (приватные, доступ через геттеры)
phone_input: String,
code_input: String,
password_input: String,
pub error_message: Option<String>,
pub status_message: Option<String>,
// Main app state (используются часто)
pub chats: Vec<ChatInfo>,
pub chat_list_state: ListState,
pub selected_chat_id: Option<ChatId>,
pub message_input: String,
/// Позиция курсора в message_input (в символах)
pub cursor_position: usize,
pub message_scroll_offset: usize,
/// None = All (основной список), Some(id) = папка с id
pub selected_folder_id: Option<i32>,
pub is_loading: bool,
// Search state
pub is_searching: bool,
pub search_query: String,
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
pub needs_redraw: bool,
// Typing indicator
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>,
// Image support
#[allow(dead_code)]
#[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро)
#[cfg(feature = "images")]
pub inline_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
/// Renderer для modal просмотра (iTerm2/Sixel - высокое качество)
#[cfg(feature = "images")]
pub modal_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
/// Состояние модального окна просмотра изображения
#[cfg(feature = "images")]
pub image_modal: Option<crate::tdlib::ImageModalState>,
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>,
/// Pending intent: открыть модалку для этого фото когда загрузится
#[cfg(feature = "images")]
pub pending_image_open: Option<PendingImageOpen>,
// Account lock
/// Advisory file lock to prevent concurrent access to the same account
pub account_lock: Option<std::fs::File>,
// Account switcher
/// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>,
/// Name of the currently active account
pub current_account_name: String,
/// Pending account switch: (account_name, db_path)
pub pending_account_switch: Option<(String, PathBuf)>,
/// Pending background chat init (reply info, photos) after fast open
pub pending_chat_init: Option<ChatId>,
/// Receiver for background chat initialization results
pub chat_init_rx: Option<tokio::sync::mpsc::UnboundedReceiver<ChatInitEvent>>,
/// Receiver for background photo downloads (file_id, result path)
#[cfg(feature = "images")]
pub photo_download_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<(i32, Result<String, String>)>>,
// Voice playback
/// Аудиопроигрыватель для голосовых сообщений (ffplay)
pub audio_player: Option<crate::audio::AudioPlayer>,
/// Кэш голосовых файлов (LRU, max 100 MB)
pub voice_cache: Option<crate::audio::VoiceCache>,
/// Состояние текущего воспроизведения
pub playback_state: Option<crate::tdlib::PlaybackState>,
/// Время последнего тика для обновления позиции воспроизведения
pub last_playback_tick: Option<std::time::Instant>,
}
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client.
///
/// Sets up empty chat list and configures the app to start on the Loading screen.
///
/// # Arguments
///
/// * `config` - Application configuration loaded from config.toml
/// * `td_client` - TDLib client instance (real or fake for tests)
///
/// # Returns
///
/// A new `App` instance ready to start authentication.
pub fn with_client(config: crate::config::Config, td_client: T) -> App<T> {
let mut state = ListState::default();
state.select(Some(0));
let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
#[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")]
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
let notification_manager = NotificationManager::from_config(&config.notifications);
App {
config,
screen: AppScreen::Loading,
td_client,
notification_manager,
chat_state: ChatState::Normal,
input_mode: InputMode::Normal,
phone_input: String::new(),
code_input: String::new(),
password_input: String::new(),
error_message: None,
status_message: Some("Инициализация TDLib...".to_string()),
chats: Vec::new(),
chat_list_state: state,
selected_chat_id: None,
message_input: String::new(),
cursor_position: 0,
message_scroll_offset: 0,
selected_folder_id: None, // None = All
is_loading: true,
is_searching: false,
search_query: String::new(),
needs_redraw: true,
last_typing_sent: None,
// Account lock
account_lock: None,
// Account switcher
account_switcher: None,
current_account_name: "default".to_string(),
pending_account_switch: None,
pending_chat_init: None,
chat_init_rx: None,
#[cfg(feature = "images")]
photo_download_rx: None,
#[cfg(feature = "images")]
image_cache,
#[cfg(feature = "images")]
inline_image_renderer,
#[cfg(feature = "images")]
modal_image_renderer,
#[cfg(feature = "images")]
image_modal: None,
#[cfg(feature = "images")]
last_image_render_time: None,
#[cfg(feature = "images")]
pending_image_open: None,
// Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
playback_state: None,
last_playback_tick: None,
}
}
/// Получить команду из KeyEvent используя настроенные keybindings.
///
/// # Arguments
///
/// * `key` - KeyEvent от пользователя
///
/// # Returns
///
/// `Some(Command)` если найдена команда для этой клавиши, `None` если нет
pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option<crate::config::Command> {
self.config.keybindings.get_command(&key)
}
/// Get the selected chat ID as i64
pub fn get_selected_chat_id(&self) -> Option<i64> {
self.selected_chat_id.map(|id| id.as_i64())
}
/// Останавливает воспроизведение голосового и сбрасывает состояние
pub fn stop_playback(&mut self) {
if let Some(ref player) = self.audio_player {
player.stop();
}
self.playback_state = None;
self.last_playback_tick = None;
self.status_message = None;
}
/// Opens the account switcher modal, loading accounts from config.
pub fn open_account_switcher(&mut self) {
let config = crate::accounts::load_or_create();
self.account_switcher = Some(AccountSwitcherState::SelectAccount {
accounts: config.accounts,
selected_index: 0,
current_account: self.current_account_name.clone(),
});
}
/// Closes the account switcher modal.
pub fn close_account_switcher(&mut self) {
self.account_switcher = None;
}
/// Navigate to previous item in account switcher list.
pub fn account_switcher_select_prev(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { selected_index, .. }) =
&mut self.account_switcher
{
*selected_index = selected_index.saturating_sub(1);
}
}
/// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
&mut self.account_switcher
{
// +1 for the "Add account" item at the end
let max_index = accounts.len();
if *selected_index < max_index {
*selected_index += 1;
}
}
}
/// Confirm selection in account switcher.
/// If on an account: sets pending_account_switch.
/// If on "+ Add": transitions to AddAccount state.
pub fn account_switcher_confirm(&mut self) {
let state = self.account_switcher.take();
match state {
Some(AccountSwitcherState::SelectAccount {
accounts,
selected_index,
current_account,
}) => {
if selected_index < accounts.len() {
// Selected an existing account
let account = &accounts[selected_index];
if account.name == current_account {
// Already on this account, just close
self.account_switcher = None;
return;
}
let db_path = account.db_path();
self.pending_account_switch = Some((account.name.clone(), db_path));
self.account_switcher = None;
} else {
// Selected "+ Add account"
self.account_switcher = Some(AccountSwitcherState::AddAccount {
name_input: String::new(),
cursor_position: 0,
error: None,
});
}
}
other => {
self.account_switcher = other;
}
}
}
/// Switch to AddAccount state from SelectAccount.
pub fn account_switcher_start_add(&mut self) {
self.account_switcher = Some(AccountSwitcherState::AddAccount {
name_input: String::new(),
cursor_position: 0,
error: None,
});
}
/// Confirm adding a new account. Validates, saves, and sets pending switch.
pub fn account_switcher_confirm_add(&mut self) {
let state = self.account_switcher.take();
match state {
Some(AccountSwitcherState::AddAccount { name_input, .. }) => {
match crate::accounts::manager::add_account(&name_input, &name_input) {
Ok(db_path) => {
self.pending_account_switch = Some((name_input, db_path));
self.account_switcher = None;
}
Err(e) => {
let cursor_pos = name_input.chars().count();
self.account_switcher = Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position: cursor_pos,
error: Some(e),
});
}
}
}
other => {
self.account_switcher = other;
}
}
}
/// Go back from AddAccount to SelectAccount.
pub fn account_switcher_back(&mut self) {
self.open_account_switcher();
}
/// Get the selected chat info
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
self.selected_chat_id
.and_then(|id| self.chats.iter().find(|c| c.id == id))
}
// ========== Getter/Setter методы для инкапсуляции ==========
// Config
pub fn config(&self) -> &crate::config::Config {
&self.config
}
// Screen
pub fn screen(&self) -> &AppScreen {
&self.screen
}
pub fn set_screen(&mut self, screen: AppScreen) {
self.screen = screen;
}
// Auth state
pub fn phone_input(&self) -> &str {
&self.phone_input
}
pub fn phone_input_mut(&mut self) -> &mut String {
&mut self.phone_input
}
pub fn set_phone_input(&mut self, input: String) {
self.phone_input = input;
}
pub fn code_input(&self) -> &str {
&self.code_input
}
pub fn code_input_mut(&mut self) -> &mut String {
&mut self.code_input
}
pub fn set_code_input(&mut self, input: String) {
self.code_input = input;
}
pub fn password_input(&self) -> &str {
&self.password_input
}
pub fn password_input_mut(&mut self) -> &mut String {
&mut self.password_input
}
pub fn set_password_input(&mut self, input: String) {
self.password_input = input;
}
pub fn error_message(&self) -> Option<&str> {
self.error_message.as_deref()
}
pub fn set_error_message(&mut self, message: Option<String>) {
self.error_message = message;
}
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn set_status_message(&mut self, message: Option<String>) {
self.status_message = message;
}
// Main app state
pub fn chats(&self) -> &[ChatInfo] {
&self.chats
}
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
&mut self.chats
}
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
self.chats = chats;
}
pub fn chat_list_state(&self) -> &ListState {
&self.chat_list_state
}
pub fn chat_list_state_mut(&mut self) -> &mut ListState {
&mut self.chat_list_state
}
pub fn selected_chat_id(&self) -> Option<ChatId> {
self.selected_chat_id
}
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
self.selected_chat_id = id;
}
pub fn message_input(&self) -> &str {
&self.message_input
}
pub fn message_input_mut(&mut self) -> &mut String {
&mut self.message_input
}
pub fn set_message_input(&mut self, input: String) {
self.message_input = input;
}
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
pub fn set_cursor_position(&mut self, pos: usize) {
self.cursor_position = pos;
}
pub fn message_scroll_offset(&self) -> usize {
self.message_scroll_offset
}
pub fn set_message_scroll_offset(&mut self, offset: usize) {
self.message_scroll_offset = offset;
}
pub fn selected_folder_id(&self) -> Option<i32> {
self.selected_folder_id
}
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
self.selected_folder_id = id;
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn set_loading(&mut self, loading: bool) {
self.is_loading = loading;
}
// Search state
pub fn is_searching(&self) -> bool {
self.is_searching
}
pub fn set_searching(&mut self, searching: bool) {
self.is_searching = searching;
}
pub fn search_query(&self) -> &str {
&self.search_query
}
pub fn search_query_mut(&mut self) -> &mut String {
&mut self.search_query
}
pub fn set_search_query(&mut self, query: String) {
self.search_query = query;
}
// Redraw flag
pub fn needs_redraw(&self) -> bool {
self.needs_redraw
}
pub fn set_needs_redraw(&mut self, redraw: bool) {
self.needs_redraw = redraw;
}
pub fn mark_for_redraw(&mut self) {
self.needs_redraw = true;
}
// Typing indicator
pub fn last_typing_sent(&self) -> Option<std::time::Instant> {
self.last_typing_sent
}
pub fn set_last_typing_sent(&mut self, time: Option<std::time::Instant>) {
self.last_typing_sent = time;
}
pub fn update_last_typing_sent(&mut self) {
self.last_typing_sent = Some(std::time::Instant::now());
}
}
// Convenience constructor for real TdClient (production use)
impl App<TdClient> {
/// Creates a new App instance with the given configuration and a real TDLib client.
///
/// This is a convenience method for production use that automatically creates
/// a new TdClient instance with the specified database path.
///
/// # Arguments
///
/// * `config` - Application configuration loaded from config.toml
/// * `db_path` - Path to the TDLib database directory for this account
///
/// # Returns
///
/// A new `App<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
let api_id = std::env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = std::env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
let td_client = TdClient::new(TdClientConfig {
credentials: TdCredentials { api_id, api_hash },
db_path,
});
App::with_client(config, td_client)
}
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug, PartialEq, Clone)]
pub enum AppScreen {
Loading,
Auth,
Main,
}

View File

@@ -0,0 +1,128 @@
/// UI состояние приложения
///
/// Отвечает за общее состояние интерфейса:
/// - Текущий экран (screen)
/// - Сообщения об ошибках и статусе
/// - Флаги загрузки и перерисовки
use crate::app::AppScreen;
/// Состояние UI приложения
#[derive(Debug, Clone)]
pub struct UIState {
/// Текущий экран приложения
pub screen: AppScreen,
/// Сообщение об ошибке (если есть)
pub error_message: Option<String>,
/// Статусное сообщение (загрузка, прогресс, и т.д.)
pub status_message: Option<String>,
/// Флаг необходимости перерисовки
pub needs_redraw: bool,
/// Флаг загрузки (общий)
pub is_loading: bool,
}
impl Default for UIState {
fn default() -> Self {
Self {
screen: AppScreen::Loading,
error_message: None,
status_message: Some("Инициализация TDLib...".to_string()),
needs_redraw: true,
is_loading: true,
}
}
}
impl UIState {
/// Создать новое UI состояние
pub fn new() -> Self {
Self::default()
}
// === Screen ===
pub fn screen(&self) -> &AppScreen {
&self.screen
}
pub fn set_screen(&mut self, screen: AppScreen) {
self.screen = screen;
self.mark_for_redraw();
}
// === Error message ===
pub fn error_message(&self) -> Option<&str> {
self.error_message.as_deref()
}
pub fn set_error_message(&mut self, message: Option<String>) {
self.error_message = message;
self.mark_for_redraw();
}
pub fn clear_error(&mut self) {
self.error_message = None;
self.mark_for_redraw();
}
// === Status message ===
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn set_status_message(&mut self, message: Option<String>) {
self.status_message = message;
self.mark_for_redraw();
}
pub fn clear_status(&mut self) {
self.status_message = None;
self.mark_for_redraw();
}
// === Redraw flag ===
pub fn needs_redraw(&self) -> bool {
self.needs_redraw
}
pub fn set_needs_redraw(&mut self, redraw: bool) {
self.needs_redraw = redraw;
}
pub fn mark_for_redraw(&mut self) {
self.needs_redraw = true;
}
pub fn clear_redraw_flag(&mut self) {
self.needs_redraw = false;
}
// === Loading flag ===
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn set_loading(&mut self, loading: bool) {
self.is_loading = loading;
if loading {
self.mark_for_redraw();
}
}
pub fn start_loading(&mut self) {
self.set_loading(true);
}
pub fn stop_loading(&mut self) {
self.set_loading(false);
}
}

View File

@@ -0,0 +1,155 @@
//! Voice message cache management.
//!
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
//! with LRU eviction when cache size exceeds limit.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Cache for voice message files
pub struct VoiceCache {
cache_dir: PathBuf,
/// file_id -> (path, size_bytes, access_count)
files: HashMap<String, (PathBuf, u64, usize)>,
access_counter: usize,
max_size_bytes: u64,
}
impl VoiceCache {
/// Creates a new VoiceCache with the given max size in MB
pub fn new(max_size_mb: u64) -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Failed to get cache directory")?
.join("tele-tui")
.join("voice");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
Ok(Self {
cache_dir,
files: HashMap::new(),
access_counter: 0,
max_size_bytes: max_size_mb * 1024 * 1024,
})
}
/// Gets the path for a cached voice file, if it exists
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
if let Some((path, _, access)) = self.files.get_mut(file_id) {
// Update access count for LRU
self.access_counter += 1;
*access = self.access_counter;
Some(path.clone())
} else {
None
}
}
/// Stores a voice file in the cache
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
// Copy file to cache
let filename = format!("{}.ogg", file_id.replace('/', "_"));
let dest_path = self.cache_dir.join(&filename);
fs::copy(source_path, &dest_path)
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
// Get file size
let size = fs::metadata(&dest_path)
.map_err(|e| format!("Failed to get file size: {}", e))?
.len();
// Store in cache
self.access_counter += 1;
self.files
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
// Check if we need to evict
self.evict_if_needed()?;
Ok(dest_path)
}
/// Returns the total size of all cached files
pub fn total_size(&self) -> u64 {
self.files.values().map(|(_, size, _)| size).sum()
}
/// Evicts oldest files until cache is under max size
fn evict_if_needed(&mut self) -> Result<(), String> {
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
// Find least recently accessed file
let oldest_id = self
.files
.iter()
.min_by_key(|(_, (_, _, access))| access)
.map(|(id, _)| id.clone());
if let Some(id) = oldest_id {
self.evict(&id)?;
}
}
Ok(())
}
/// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
}
Ok(())
}
/// Clears all cached files
#[allow(dead_code)]
pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors
}
self.files.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_voice_cache_creation() {
let cache = VoiceCache::new(100);
assert!(cache.is_ok());
}
#[test]
fn test_cache_get_nonexistent() {
let mut cache = VoiceCache::new(100).unwrap();
assert!(cache.get("nonexistent").is_none());
}
#[test]
fn test_cache_store_and_get() {
let mut cache = VoiceCache::new(100).unwrap();
// Create temporary file
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_voice.ogg");
let mut file = fs::File::create(&temp_file).unwrap();
file.write_all(b"test audio data").unwrap();
// Store in cache
let result = cache.store("test123", &temp_file);
assert!(result.is_ok());
// Get from cache
let cached_path = cache.get("test123");
assert!(cached_path.is_some());
assert!(cached_path.unwrap().exists());
// Cleanup
fs::remove_file(&temp_file).unwrap();
}
}

View File

@@ -0,0 +1,11 @@
//! Audio playback module for voice messages.
//!
//! Provides:
//! - AudioPlayer: ffplay-based playback with play/pause/seek controls
//! - VoiceCache: LRU cache for downloaded OGG voice files
pub mod cache;
pub mod player;
pub use cache::VoiceCache;
pub use player::AudioPlayer;

View File

@@ -0,0 +1,205 @@
//! Audio player for voice messages.
//!
//! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback.
//! Pause/resume implemented via SIGSTOP/SIGCONT signals.
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Audio player state and controls
pub struct AudioPlayer {
/// PID of current playback process (if any)
current_pid: Arc<Mutex<Option<u32>>>,
/// Whether the process is currently paused (SIGSTOP)
paused: Arc<Mutex<bool>>,
/// Path to the currently playing file (for restart with seek)
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
/// True between play_from() call and ffplay actually starting (race window)
starting: Arc<Mutex<bool>>,
}
impl AudioPlayer {
/// Creates a new AudioPlayer
pub fn new() -> Result<Self, String> {
let ffplay_check = Command::new("which")
.arg("ffplay")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
if !ffplay_check.status.success() {
return Err("ffplay not found (install ffmpeg)".to_string());
}
Ok(Self {
current_pid: Arc::new(Mutex::new(None)),
paused: Arc::new(Mutex::new(false)),
current_path: Arc::new(Mutex::new(None)),
starting: Arc::new(Mutex::new(false)),
})
}
/// Plays an audio file from the given path
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
self.play_from(path, 0.0)
}
/// Plays an audio file starting from the given position (seconds)
pub fn play_from<P: AsRef<Path>>(&self, path: P, start_secs: f32) -> Result<(), String> {
self.stop();
let path_owned = path.as_ref().to_path_buf();
*self.starting.lock().unwrap() = true;
let current_pid = self.current_pid.clone();
let paused = self.paused.clone();
let starting = self.starting.clone();
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
}
let mut child = match cmd
.arg(&path_owned)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => {
*self.starting.lock().unwrap() = false;
return Err(format!("failed to start ffplay: {}", e));
}
};
let pid = child.id();
*self.current_path.lock().unwrap() = Some(path_owned);
*current_pid.lock().unwrap() = Some(pid);
*paused.lock().unwrap() = false;
*starting.lock().unwrap() = false;
std::thread::spawn(move || {
let _ = child.wait();
// Обнуляем только если это наш pid (новый play мог уже заменить его)
let mut pid_guard = current_pid.lock().unwrap();
if *pid_guard == Some(pid) {
*pid_guard = None;
*paused.lock().unwrap() = false;
}
});
Ok(())
}
/// Pauses playback via SIGSTOP
pub fn pause(&self) {
if let Some(pid) = *self.current_pid.lock().unwrap() {
let _ = Command::new("kill")
.arg("-STOP")
.arg(pid.to_string())
.output();
*self.paused.lock().unwrap() = true;
}
}
/// Resumes playback via SIGCONT (from the same position)
pub fn resume(&self) {
if let Some(pid) = *self.current_pid.lock().unwrap() {
let _ = Command::new("kill")
.arg("-CONT")
.arg(pid.to_string())
.output();
*self.paused.lock().unwrap() = false;
}
}
/// Resumes playback from a specific position (restarts ffplay with -ss)
pub fn resume_from(&self, position_secs: f32) -> Result<(), String> {
let path = self.current_path.lock().unwrap().clone();
if let Some(path) = path {
self.play_from(&path, position_secs)
} else {
Err("No file to resume".to_string())
}
}
/// Stops playback (kills the process)
pub fn stop(&self) {
*self.starting.lock().unwrap() = false;
if let Some(pid) = self.current_pid.lock().unwrap().take() {
// Resume first if paused, then kill
let _ = Command::new("kill")
.arg("-CONT")
.arg(pid.to_string())
.output();
let _ = Command::new("kill").arg(pid.to_string()).output();
}
*self.paused.lock().unwrap() = false;
}
/// Returns true if a process is active (playing or paused)
#[allow(dead_code)]
pub fn is_playing(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
}
/// Returns true if paused
#[allow(dead_code)]
pub fn is_paused(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
}
/// Returns true if no active process and not starting a new one
pub fn is_stopped(&self) -> bool {
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
}
#[allow(dead_code)]
pub fn set_volume(&self, _volume: f32) {}
#[allow(dead_code)]
pub fn adjust_volume(&self, _delta: f32) {}
pub fn volume(&self) -> f32 {
1.0
}
#[allow(dead_code)]
pub fn seek(&self, _delta: Duration) -> Result<(), String> {
Err("Seeking not supported".to_string())
}
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audio_player_creation() {
if let Ok(player) = AudioPlayer::new() {
assert!(player.is_stopped());
assert!(!player.is_playing());
assert!(!player.is_paused());
}
}
#[test]
fn test_volume() {
if let Ok(player) = AudioPlayer::new() {
assert_eq!(player.volume(), 1.0);
}
}
}

View File

@@ -0,0 +1,182 @@
use std::io;
use std::time::Duration;
use crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
Event, KeyCode, KeyEvent, KeyModifiers,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use tele_tui::{
app::{App, AppScreen},
input::handle_main_input,
test_support::{
app_builder::TestAppBuilder,
fake_tdclient::FakeTdClient,
test_data::{TestChatBuilder, TestMessageBuilder},
},
};
#[tokio::main]
async fn main() -> io::Result<()> {
let scenario = parse_scenario();
let mut app = build_app(&scenario);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_fixture(&mut terminal, &mut app).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture,
DisableBracketedPaste
)?;
terminal.show_cursor()?;
result
}
fn parse_scenario() -> String {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--scenario" {
return args.next().unwrap_or_else(|| "inbox".to_string());
}
}
"inbox".to_string()
}
async fn run_fixture(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App<FakeTdClient>,
) -> io::Result<()> {
loop {
if app.needs_redraw {
terminal.draw(|f| tele_tui::ui::render(f, app))?;
app.needs_redraw = false;
}
if event::poll(Duration::from_millis(16))? {
match event::read()? {
Event::Key(key) => {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return Ok(());
}
if key.code == KeyCode::F(10) {
return Ok(());
}
handle_main_input(app, normalize_fixture_key(key)).await;
app.needs_redraw = true;
}
Event::Resize(_, _) => {
app.needs_redraw = true;
}
Event::Paste(text) => {
for ch in text.chars() {
handle_main_input(
app,
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
)
.await;
}
app.needs_redraw = true;
}
_ => {}
}
}
}
}
fn normalize_fixture_key(key: KeyEvent) -> KeyEvent {
match (key.code, key.modifiers) {
(KeyCode::Char('/'), KeyModifiers::NONE) => {
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)
}
(KeyCode::Char('j' | 'm'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
}
_ => key,
}
}
fn build_app(scenario: &str) -> App<FakeTdClient> {
match scenario {
"open-chat" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.with_messages(102, sample_messages())
.build(),
"compose-draft" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.selected_chat(102)
.message_input("hello from e2e")
.with_messages(102, sample_messages())
.build(),
"inbox" => TestAppBuilder::new()
.screen(AppScreen::Main)
.with_chats(sample_chats())
.with_messages(101, mom_messages())
.with_messages(102, sample_messages())
.with_messages(103, boss_messages())
.build(),
other => {
eprintln!("unknown scenario: {other}");
std::process::exit(2);
}
}
}
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
vec![
TestChatBuilder::new("Mom", 101)
.last_message("Dinner at 7?")
.unread_count(2)
.build(),
TestChatBuilder::new("Work Group", 102)
.last_message("Standup notes are ready")
.unread_mentions(1)
.build(),
TestChatBuilder::new("Boss", 103)
.last_message("Please review the deck")
.build(),
]
}
fn sample_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![
TestMessageBuilder::new("Morning, team", 201)
.sender("Alice")
.build(),
TestMessageBuilder::new("Standup notes are ready", 202)
.sender("Bob")
.build(),
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
.outgoing()
.build(),
]
}
fn mom_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![TestMessageBuilder::new("Dinner at 7?", 301)
.sender("Mom")
.build()]
}
fn boss_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
vec![TestMessageBuilder::new("Please review the deck", 401)
.sender("Boss")
.build()]
}

View File

@@ -0,0 +1,556 @@
/// Модуль для настраиваемых горячих клавиш
///
/// Поддерживает:
/// - Загрузку из конфигурационного файла
/// - Множественные binding для одной команды (EN/RU раскладки)
/// - Type-safe команды через enum
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Команды приложения
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Command {
// Navigation
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
PageUp,
PageDown,
// Global
Quit,
OpenSearch,
OpenSearchInChat,
Help,
// Chat list
OpenChat,
SelectFolder1,
SelectFolder2,
SelectFolder3,
SelectFolder4,
SelectFolder5,
SelectFolder6,
SelectFolder7,
SelectFolder8,
SelectFolder9,
// Message actions
EditMessage,
DeleteMessage,
ReplyMessage,
ForwardMessage,
CopyMessage,
ReactMessage,
SelectMessage,
// Media
ViewImage, // v - просмотр фото
// Voice playback
TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s
// Input
SubmitMessage,
Cancel,
NewLine,
DeleteChar,
DeleteWord,
MoveToStart,
MoveToEnd,
// Vim mode
EnterInsertMode,
// Profile
OpenProfile,
}
const COMMAND_LOOKUP_ORDER: &[Command] = &[
Command::Quit,
Command::Cancel,
Command::SubmitMessage,
Command::OpenSearch,
Command::OpenSearchInChat,
Command::OpenProfile,
Command::Help,
Command::MoveUp,
Command::MoveDown,
Command::MoveLeft,
Command::MoveRight,
Command::PageUp,
Command::PageDown,
Command::SelectFolder1,
Command::SelectFolder2,
Command::SelectFolder3,
Command::SelectFolder4,
Command::SelectFolder5,
Command::SelectFolder6,
Command::SelectFolder7,
Command::SelectFolder8,
Command::SelectFolder9,
Command::OpenChat,
Command::EditMessage,
Command::DeleteMessage,
Command::ReplyMessage,
Command::ForwardMessage,
Command::CopyMessage,
Command::ReactMessage,
Command::SelectMessage,
Command::ViewImage,
Command::TogglePlayback,
Command::SeekForward,
Command::SeekBackward,
Command::NewLine,
Command::DeleteChar,
Command::DeleteWord,
Command::MoveToStart,
Command::MoveToEnd,
Command::EnterInsertMode,
];
/// Привязка клавиши к команде
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct KeyBinding {
#[serde(with = "key_code_serde")]
pub key: KeyCode,
#[serde(with = "key_modifiers_serde")]
pub modifiers: KeyModifiers,
}
impl KeyBinding {
pub fn new(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::NONE }
}
pub fn with_ctrl(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::CONTROL }
}
#[allow(dead_code)]
pub fn with_shift(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::SHIFT }
}
#[allow(dead_code)]
pub fn with_alt(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::ALT }
}
pub fn matches(&self, event: &KeyEvent) -> bool {
self.key == event.code && self.modifiers == event.modifiers
}
}
/// Конфигурация горячих клавиш
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Keybindings {
#[serde(flatten)]
bindings: HashMap<Command, Vec<KeyBinding>>,
}
impl Keybindings {
/// Ищет команду по клавише
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
for command in COMMAND_LOOKUP_ORDER {
if self
.bindings
.get(command)
.is_some_and(|bindings| bindings.iter().any(|binding| binding.matches(event)))
{
return Some(*command);
}
}
None
}
#[cfg(test)]
fn duplicate_bindings(&self) -> Vec<(KeyBinding, Vec<Command>)> {
let mut by_key: HashMap<KeyBinding, Vec<Command>> = HashMap::new();
for command in COMMAND_LOOKUP_ORDER {
if let Some(bindings) = self.bindings.get(command) {
for binding in bindings {
by_key.entry(binding.clone()).or_default().push(*command);
}
}
}
by_key
.into_iter()
.filter(|(_, commands)| commands.len() > 1)
.collect()
}
}
impl Default for Keybindings {
fn default() -> Self {
let mut bindings = HashMap::new();
// Navigation
bindings.insert(
Command::MoveUp,
vec![
KeyBinding::new(KeyCode::Up),
KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Char('л')), // RU
],
);
bindings.insert(
Command::MoveDown,
vec![
KeyBinding::new(KeyCode::Down),
KeyBinding::new(KeyCode::Char('j')),
KeyBinding::new(KeyCode::Char('о')), // RU
],
);
bindings.insert(
Command::MoveLeft,
vec![
KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('р')), // RU
],
);
bindings.insert(
Command::MoveRight,
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
bindings.insert(
Command::Quit,
vec![
KeyBinding::new(KeyCode::Char('q')),
KeyBinding::new(KeyCode::Char('й')), // RU
KeyBinding::with_ctrl(KeyCode::Char('c')),
],
);
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
// Chat list
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
for i in 1..=9 {
let cmd = match i {
1 => Command::SelectFolder1,
2 => Command::SelectFolder2,
3 => Command::SelectFolder3,
4 => Command::SelectFolder4,
5 => Command::SelectFolder5,
6 => Command::SelectFolder6,
7 => Command::SelectFolder7,
8 => Command::SelectFolder8,
9 => Command::SelectFolder9,
_ => unreachable!(),
};
bindings.insert(
cmd,
vec![KeyBinding::new(KeyCode::Char(
char::from_digit(i, 10).unwrap(),
))],
);
}
// Message actions
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
// конфликтовать с Command::MoveUp в списке чатов.
bindings.insert(
Command::DeleteMessage,
vec![
KeyBinding::new(KeyCode::Delete),
KeyBinding::new(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Char('в')), // RU
],
);
bindings.insert(
Command::ReplyMessage,
vec![
KeyBinding::new(KeyCode::Char('r')),
KeyBinding::new(KeyCode::Char('к')), // RU
],
);
bindings.insert(
Command::ForwardMessage,
vec![
KeyBinding::new(KeyCode::Char('f')),
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()
// Media
bindings.insert(
Command::ViewImage,
vec![
KeyBinding::new(KeyCode::Char('v')),
KeyBinding::new(KeyCode::Char('м')), // RU
],
);
// Voice playback
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]);
// Left/Right are MoveLeft/MoveRight globally; message selection treats them as voice seek.
bindings.insert(Command::SeekForward, vec![]);
bindings.insert(Command::SeekBackward, vec![]);
// Input
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]);
bindings.insert(Command::NewLine, vec![]);
bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]);
bindings.insert(
Command::DeleteWord,
vec![
KeyBinding::with_ctrl(KeyCode::Backspace),
KeyBinding::with_ctrl(KeyCode::Char('w')),
],
);
bindings.insert(Command::MoveToStart, vec![KeyBinding::new(KeyCode::Home)]);
bindings.insert(
Command::MoveToEnd,
vec![
KeyBinding::new(KeyCode::End),
KeyBinding::with_ctrl(KeyCode::Char('e')),
],
);
// Vim mode
bindings.insert(
Command::EnterInsertMode,
vec![
KeyBinding::new(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU
],
);
// Profile
bindings.insert(
Command::OpenProfile,
vec![
// Во многих терминалах Ctrl+I приходит как Tab
KeyBinding::new(KeyCode::Tab),
KeyBinding::with_ctrl(KeyCode::Char('i')),
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
],
);
Self { bindings }
}
}
/// Сериализация KeyModifiers
mod key_modifiers_serde {
use crossterm::event::KeyModifiers;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(modifiers: &KeyModifiers, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut parts = Vec::new();
if modifiers.contains(KeyModifiers::SHIFT) {
parts.push("Shift");
}
if modifiers.contains(KeyModifiers::CONTROL) {
parts.push("Ctrl");
}
if modifiers.contains(KeyModifiers::ALT) {
parts.push("Alt");
}
if modifiers.contains(KeyModifiers::SUPER) {
parts.push("Super");
}
if modifiers.contains(KeyModifiers::HYPER) {
parts.push("Hyper");
}
if modifiers.contains(KeyModifiers::META) {
parts.push("Meta");
}
if parts.is_empty() {
serializer.serialize_str("None")
} else {
serializer.serialize_str(&parts.join("+"))
}
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<KeyModifiers, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s == "None" || s.is_empty() {
return Ok(KeyModifiers::NONE);
}
let mut modifiers = KeyModifiers::NONE;
for part in s.split('+') {
match part.trim() {
"Shift" => modifiers |= KeyModifiers::SHIFT,
"Ctrl" | "Control" => modifiers |= KeyModifiers::CONTROL,
"Alt" => modifiers |= KeyModifiers::ALT,
"Super" => modifiers |= KeyModifiers::SUPER,
"Hyper" => modifiers |= KeyModifiers::HYPER,
"Meta" => modifiers |= KeyModifiers::META,
_ => return Err(serde::de::Error::custom(format!("Unknown modifier: {}", part))),
}
}
Ok(modifiers)
}
}
/// Сериализация KeyCode
mod key_code_serde {
use crossterm::event::KeyCode;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S>(key: &KeyCode, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = match key {
KeyCode::Char(c) => format!("Char('{}')", c),
KeyCode::F(n) => format!("F{}", n),
KeyCode::Backspace => "Backspace".to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Left => "Left".to_string(),
KeyCode::Right => "Right".to_string(),
KeyCode::Up => "Up".to_string(),
KeyCode::Down => "Down".to_string(),
KeyCode::Home => "Home".to_string(),
KeyCode::End => "End".to_string(),
KeyCode::PageUp => "PageUp".to_string(),
KeyCode::PageDown => "PageDown".to_string(),
KeyCode::Tab => "Tab".to_string(),
KeyCode::BackTab => "BackTab".to_string(),
KeyCode::Delete => "Delete".to_string(),
KeyCode::Insert => "Insert".to_string(),
KeyCode::Esc => "Esc".to_string(),
_ => "Unknown".to_string(),
};
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<KeyCode, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s.starts_with("Char('") && s.ends_with("')") {
let c = s
.chars()
.nth(6)
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
return Ok(KeyCode::Char(c));
}
if let Some(suffix) = s.strip_prefix("F") {
let n = suffix.parse().map_err(serde::de::Error::custom)?;
return Ok(KeyCode::F(n));
}
match s.as_str() {
"Backspace" => Ok(KeyCode::Backspace),
"Enter" => Ok(KeyCode::Enter),
"Left" => Ok(KeyCode::Left),
"Right" => Ok(KeyCode::Right),
"Up" => Ok(KeyCode::Up),
"Down" => Ok(KeyCode::Down),
"Home" => Ok(KeyCode::Home),
"End" => Ok(KeyCode::End),
"PageUp" => Ok(KeyCode::PageUp),
"PageDown" => Ok(KeyCode::PageDown),
"Tab" => Ok(KeyCode::Tab),
"BackTab" => Ok(KeyCode::BackTab),
"Delete" => Ok(KeyCode::Delete),
"Insert" => Ok(KeyCode::Insert),
"Esc" => Ok(KeyCode::Esc),
_ => Err(serde::de::Error::custom(format!("Unknown key: {}", s))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_bindings() {
let kb = Keybindings::default();
// Проверяем навигацию
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('л'))), Some(Command::MoveUp));
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveLeft));
}
#[test]
fn test_get_command() {
let kb = Keybindings::default();
let event = KeyEvent::from(KeyCode::Char('q'));
assert_eq!(kb.get_command(&event), Some(Command::Quit));
let event = KeyEvent::from(KeyCode::Char('й')); // RU
assert_eq!(kb.get_command(&event), Some(Command::Quit));
}
#[test]
fn test_ctrl_modifier() {
let kb = Keybindings::default();
let mut event = KeyEvent::from(KeyCode::Char('s'));
event.modifiers = KeyModifiers::CONTROL;
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch));
}
#[test]
fn test_default_bindings_have_no_conflicts() {
let kb = Keybindings::default();
let duplicates = kb.duplicate_bindings();
assert!(duplicates.is_empty(), "duplicate default keybindings: {:?}", duplicates);
}
}

View File

@@ -0,0 +1,197 @@
//! Config file loading, saving, and credentials management.
//!
//! Searches for config at `~/.config/tele-tui/config.toml`.
//! Credentials loaded from file or environment variables.
use std::fs;
use std::path::PathBuf;
use super::Config;
impl Config {
/// Возвращает путь к конфигурационному файлу.
///
/// # Returns
///
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
/// `None` - Не удалось определить директорию конфигурации
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загружает конфигурацию из файла.
///
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
/// Если файл не существует, создаёт дефолтный.
/// Если файл невалиден, возвращает дефолтные значения.
///
/// # Returns
///
/// Всегда возвращает валидную конфигурацию.
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
tracing::warn!("Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => {
// Валидируем загруженный конфиг
if let Err(e) = config.validate() {
tracing::error!("Config validation error: {}", e);
tracing::warn!("Using default configuration instead");
Self::default()
} else {
config
}
}
Err(e) => {
tracing::warn!("Could not parse config file: {}", e);
Self::default()
}
},
Err(e) => {
tracing::warn!("Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохраняет конфигурацию в файл.
///
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
///
/// # Returns
///
/// * `Ok(())` - Конфиг сохранен
/// * `Err(String)` - Ошибка сохранения
pub fn save(&self) -> Result<(), String> {
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH для Telegram.
///
/// Ищет credentials в следующем порядке:
/// 1. `~/.config/tele-tui/credentials` файл
/// 2. Переменные окружения `API_ID` и `API_HASH`
///
/// # Returns
///
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
/// * `Err(String)` - Ошибка с инструкциями по настройке
pub fn load_credentials() -> Result<(i32, String), String> {
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials);
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let Some(credentials) = Self::load_credentials_from_env() {
return Ok(credentials);
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
/// Загружает credentials из файла ~/.config/tele-tui/credentials
fn load_credentials_from_file() -> Option<(i32, String)> {
let cred_path = Self::credentials_path()?;
if !cred_path.exists() {
return None;
}
let content = fs::read_to_string(&cred_path).ok()?;
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => api_id = value.parse().ok(),
"API_HASH" => api_hash = Some(value.to_string()),
_ => {}
}
}
Some((api_id?, api_hash?))
}
/// Загружает credentials из переменных окружения (.env)
fn load_credentials_from_env() -> Option<(i32, String)> {
use std::env;
let api_id_str = env::var("API_ID").ok()?;
let api_hash = env::var("API_HASH").ok()?;
let api_id = api_id_str.parse::<i32>().ok()?;
Some((api_id, api_hash))
}
}

View File

@@ -0,0 +1,401 @@
//! Configuration module.
//!
//! Loads settings from `~/.config/tele-tui/config.toml`.
//! Structs: Config, ColorsConfig, NotificationsConfig, Keybindings.
pub mod keybindings;
mod loader;
mod validation;
use serde::{Deserialize, Serialize};
pub use keybindings::{Command, Keybindings};
/// Главная конфигурация приложения.
///
/// Загружается из `~/.config/tele-tui/config.toml` и содержит настройки
/// общего поведения, цветовой схемы и горячих клавиш.
///
/// # Examples
///
/// ```ignore
/// // Загрузка конфигурации
/// let config = Config::load();
///
/// // Доступ к настройкам
/// println!("Incoming color: {}", config.colors.incoming_message);
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
/// Цветовая схема интерфейса.
#[serde(default)]
pub colors: ColorsConfig,
/// Горячие клавиши.
#[serde(default)]
pub keybindings: Keybindings,
/// Настройки desktop notifications.
#[serde(default)]
pub notifications: NotificationsConfig,
/// Настройки отображения изображений.
#[serde(default)]
pub images: ImagesConfig,
/// Настройки аудио (голосовые сообщения).
#[serde(default)]
pub audio: AudioConfig,
}
/// Цветовая схема интерфейса.
///
/// Поддерживаемые цвета: red, green, blue, yellow, cyan, magenta,
/// white, black, gray/grey, а также light-варианты (lightred, lightgreen и т.д.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorsConfig {
/// Цвет входящих сообщений (white, gray, cyan и т.д.)
#[serde(default = "default_incoming_color")]
pub incoming_message: String,
/// Цвет исходящих сообщений
#[serde(default = "default_outgoing_color")]
pub outgoing_message: String,
/// Цвет выбранного сообщения
#[serde(default = "default_selected_color")]
pub selected_message: String,
/// Цвет своих реакций
#[serde(default = "default_reaction_chosen_color")]
pub reaction_chosen: String,
/// Цвет чужих реакций
#[serde(default = "default_reaction_other_color")]
pub reaction_other: String,
}
/// Настройки desktop notifications.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationsConfig {
/// Включить/выключить уведомления
#[serde(default = "default_notifications_enabled")]
pub enabled: bool,
/// Уведомлять только при @упоминаниях
#[serde(default)]
pub only_mentions: bool,
/// Показывать превью текста сообщения
#[serde(default = "default_show_preview")]
pub show_preview: bool,
/// Продолжительность показа уведомления (миллисекунды)
/// 0 = системное значение по умолчанию
#[serde(default = "default_notification_timeout")]
pub timeout_ms: i32,
/// Уровень важности: "low", "normal", "critical"
#[serde(default = "default_notification_urgency")]
pub urgency: String,
}
/// Настройки отображения изображений.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImagesConfig {
/// Показывать превью изображений в чате
#[serde(default = "default_show_images")]
pub show_images: bool,
/// Размер кэша изображений (в МБ)
#[serde(default = "default_image_cache_size_mb")]
pub cache_size_mb: u64,
/// Максимальная ширина inline превью (в символах)
#[serde(default = "default_inline_image_max_width")]
pub inline_image_max_width: usize,
/// Автоматически загружать изображения при открытии чата
#[serde(default = "default_auto_download_images")]
pub auto_download_images: bool,
}
impl Default for ImagesConfig {
fn default() -> Self {
Self {
show_images: default_show_images(),
cache_size_mb: default_image_cache_size_mb(),
inline_image_max_width: default_inline_image_max_width(),
auto_download_images: default_auto_download_images(),
}
}
}
/// Настройки аудио (голосовые сообщения).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioConfig {
/// Размер кэша голосовых файлов (в МБ)
#[serde(default = "default_audio_cache_size_mb")]
pub cache_size_mb: u64,
/// Автоматически загружать голосовые при открытии чата
#[serde(default = "default_auto_download_voice")]
pub auto_download_voice: bool,
}
impl Default for AudioConfig {
fn default() -> Self {
Self {
cache_size_mb: default_audio_cache_size_mb(),
auto_download_voice: default_auto_download_voice(),
}
}
}
// Дефолтные значения (используются serde атрибутами)
fn default_incoming_color() -> String {
"white".to_string()
}
fn default_outgoing_color() -> String {
"green".to_string()
}
fn default_selected_color() -> String {
"yellow".to_string()
}
fn default_reaction_chosen_color() -> String {
"yellow".to_string()
}
fn default_reaction_other_color() -> String {
"gray".to_string()
}
fn default_notifications_enabled() -> bool {
false
}
fn default_show_preview() -> bool {
true
}
fn default_notification_timeout() -> i32 {
5000 // 5 seconds
}
fn default_notification_urgency() -> String {
"normal".to_string()
}
fn default_show_images() -> bool {
true
}
fn default_image_cache_size_mb() -> u64 {
crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB
}
fn default_inline_image_max_width() -> usize {
crate::constants::INLINE_IMAGE_MAX_WIDTH
}
fn default_auto_download_images() -> bool {
true
}
fn default_audio_cache_size_mb() -> u64 {
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
}
fn default_auto_download_voice() -> bool {
false
}
impl Default for ColorsConfig {
fn default() -> Self {
Self {
incoming_message: default_incoming_color(),
outgoing_message: default_outgoing_color(),
selected_message: default_selected_color(),
reaction_chosen: default_reaction_chosen_color(),
reaction_other: default_reaction_other_color(),
}
}
}
impl Default for NotificationsConfig {
fn default() -> Self {
Self {
enabled: default_notifications_enabled(),
only_mentions: false,
show_preview: default_show_preview(),
timeout_ms: default_notification_timeout(),
urgency: default_notification_urgency(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn test_config_default_includes_keybindings() {
let config = Config::default();
let keybindings = &config.keybindings;
// Test that keybindings exist for common commands
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
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]
fn test_config_validate_valid() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_config_validate_invalid_color_incoming() {
let mut config = Config::default();
config.colors.incoming_message = "rainbow".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid color"));
}
#[test]
fn test_config_validate_invalid_color_outgoing() {
let mut config = Config::default();
config.colors.outgoing_message = "purple".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid color"));
}
#[test]
fn test_config_validate_invalid_color_selected() {
let mut config = Config::default();
config.colors.selected_message = "pink".to_string();
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid color"));
}
#[test]
fn test_config_validate_valid_all_standard_colors() {
let colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color in colors {
let mut config = Config::default();
config.colors.incoming_message = color.to_string();
config.colors.outgoing_message = color.to_string();
config.colors.selected_message = color.to_string();
config.colors.reaction_chosen = color.to_string();
config.colors.reaction_other = color.to_string();
assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
}
}
#[test]
fn test_config_validate_case_insensitive_colors() {
let mut config = Config::default();
config.colors.incoming_message = "RED".to_string();
config.colors.outgoing_message = "Green".to_string();
config.colors.selected_message = "YELLOW".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_parse_color_standard() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("red"), Color::Red);
assert_eq!(config.parse_color("green"), Color::Green);
assert_eq!(config.parse_color("blue"), Color::Blue);
}
#[test]
fn test_parse_color_light_variants() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("lightred"), Color::LightRed);
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
}
#[test]
fn test_parse_color_gray_variants() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("gray"), Color::Gray);
assert_eq!(config.parse_color("grey"), Color::Gray);
assert_eq!(config.parse_color("darkgray"), Color::DarkGray);
assert_eq!(config.parse_color("darkgrey"), Color::DarkGray);
}
#[test]
fn test_parse_color_case_insensitive() {
let config = Config::default();
use ratatui::style::Color;
assert_eq!(config.parse_color("RED"), Color::Red);
assert_eq!(config.parse_color("Green"), Color::Green);
assert_eq!(config.parse_color("LIGHTBLUE"), Color::LightBlue);
}
#[test]
fn test_parse_color_invalid_fallback() {
let config = Config::default();
use ratatui::style::Color;
// Invalid colors should fallback to White
assert_eq!(config.parse_color("rainbow"), Color::White);
assert_eq!(config.parse_color("purple"), Color::White);
assert_eq!(config.parse_color("unknown"), Color::White);
}
}

View File

@@ -0,0 +1,80 @@
//! Config validation: color names, notification settings.
use super::Config;
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка цветов
let valid_colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color_name in [
&self.colors.incoming_message,
&self.colors.outgoing_message,
&self.colors.selected_message,
&self.colors.reaction_chosen,
&self.colors.reaction_other,
] {
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
return Err(format!("Invalid color: {}", color_name));
}
}
Ok(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
}

View File

@@ -0,0 +1,90 @@
//! Application-wide constants (memory limits, timeouts, UI sizes).
// ============================================================================
// Memory Limits
// ============================================================================
/// Максимальное количество сообщений в одном чате (для оптимизации памяти)
#[allow(dead_code)]
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
/// Максимальный размер кэша пользователей (LRU)
#[allow(dead_code)]
pub const MAX_USER_CACHE_SIZE: usize = 500;
/// Максимальное количество чатов для загрузки
#[allow(dead_code)]
pub const MAX_CHATS: usize = 200;
/// Максимальное количество user_ids для хранения в чате
#[allow(dead_code)]
pub const MAX_CHAT_USER_IDS: usize = 500;
// ============================================================================
// Performance
// ============================================================================
/// Таймаут poll для event loop (16ms = 60 FPS)
pub const POLL_TIMEOUT_MS: u64 = 16;
/// Таймаут ожидания graceful shutdown (в секундах)
pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2;
/// Количество пользователей для ленивой загрузки за один тик
#[allow(dead_code)]
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
// ============================================================================
// TDLib
// ============================================================================
/// Лимит количества сообщений для загрузки через TDLib за раз
#[allow(dead_code)]
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
// ============================================================================
// Images
// ============================================================================
/// Максимальная ширина превью изображения (в символах)
pub const MAX_IMAGE_WIDTH: u16 = 30;
/// Максимальная высота превью изображения (в строках)
pub const MAX_IMAGE_HEIGHT: u16 = 15;
/// Минимальная высота превью изображения (в строках)
pub const MIN_IMAGE_HEIGHT: u16 = 3;
/// Таймаут скачивания файла (в секундах)
#[allow(dead_code)]
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
/// Размер кэша изображений по умолчанию (в МБ)
pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
/// Максимальная ширина inline превью изображений (в символах)
#[cfg(feature = "images")]
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
/// Ширина одного фото в альбоме (в символах)
#[cfg(feature = "images")]
pub const ALBUM_PHOTO_WIDTH: u16 = 16;
/// Высота одного фото в альбоме (в строках)
#[cfg(feature = "images")]
pub const ALBUM_PHOTO_HEIGHT: u16 = 8;
/// Отступ между фото в альбоме (в символах)
#[cfg(feature = "images")]
pub const ALBUM_PHOTO_GAP: u16 = 1;
/// Максимальное количество фото в одном ряду альбома
#[cfg(feature = "images")]
pub const ALBUM_GRID_MAX_COLS: usize = 3;
// ============================================================================
// Audio
// ============================================================================
/// Размер кэша голосовых сообщений по умолчанию (в МБ)
pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100;

View File

@@ -0,0 +1,329 @@
//! Модуль для форматирования текста с markdown entities
//!
//! Предоставляет функции для преобразования текста с TDLib TextEntity
//! в стилизованные Span для отображения в TUI.
use ratatui::{
style::{Color, Modifier, Style},
text::Span,
};
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
/// Структура для хранения стиля символа
#[derive(Clone, Default)]
struct CharStyle {
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
code: bool,
spoiler: bool,
url: bool,
mention: bool,
}
impl CharStyle {
/// Преобразует CharStyle в ratatui Style
fn to_style(&self, base_color: Color) -> Style {
let mut style = Style::default();
if self.code {
// Код отображается cyan на тёмном фоне
style = style.fg(Color::Cyan).bg(Color::DarkGray);
} else if self.spoiler {
// Спойлер — серый текст (скрытый)
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
} else if self.url || self.mention {
// Ссылки и упоминания — синий с подчёркиванием
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
} else {
style = style.fg(base_color);
}
if self.bold {
style = style.add_modifier(Modifier::BOLD);
}
if self.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if self.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if self.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
style
}
}
/// Проверяет равенство двух стилей
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
a.bold == b.bold
&& a.italic == b.italic
&& a.underline == b.underline
&& a.strikethrough == b.strikethrough
&& a.code == b.code
&& a.spoiler == b.spoiler
&& a.url == b.url
&& a.mention == b.mention
}
/// Преобразует текст с TDLib entities в стилизованные Span для рендеринга.
///
/// Обрабатывает Markdown форматирование (bold, italic, code и т.д.) и преобразует
/// в визуальные стили для отображения в TUI.
///
/// # Поддерживаемые стили
///
/// - **Bold** - жирный текст
/// - *Italic* - курсив
/// - __Underline__ - подчёркнутый
/// - ~~Strikethrough~~ - зачёркнутый
/// - `Code` - моноширинный текст (cyan на тёмном фоне)
/// - ||Spoiler|| - скрытый текст (серый)
/// - [URL](url) - ссылки (синий с подчёркиванием)
/// - @mentions - упоминания (синий с подчёркиванием)
///
/// # Arguments
///
/// * `text` - Текст для форматирования
/// * `entities` - Массив TDLib TextEntity с информацией о форматировании
/// * `base_color` - Базовый цвет для обычного текста
///
/// # Returns
///
/// Вектор стилизованных `Span<'static>` для рендеринга в ratatui.
///
/// # Examples
///
/// ```ignore
/// let spans = format_text_with_entities(
/// "Hello **world**!",
/// &entities,
/// Color::White
/// );
/// ```
pub fn format_text_with_entities(
text: &str,
entities: &[TextEntity],
base_color: Color,
) -> Vec<Span<'static>> {
if entities.is_empty() {
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
// Создаём массив стилей для каждого символа
let chars: Vec<char> = text.chars().collect();
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
// Применяем entities к символам
for entity in entities {
let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize;
for item in char_styles
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
match &entity.r#type {
TextEntityType::Bold => item.bold = true,
TextEntityType::Italic => item.italic = true,
TextEntityType::Underline => item.underline = true,
TextEntityType::Strikethrough => item.strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
item.code = true
}
TextEntityType::Spoiler => item.spoiler = true,
TextEntityType::Url
| TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => item.url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
_ => {}
}
}
}
// Группируем последовательные символы с одинаковым стилем
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current_text = String::new();
let mut current_style: Option<CharStyle> = None;
for (i, ch) in chars.iter().enumerate() {
let style = &char_styles[i];
match &current_style {
Some(prev_style) if styles_equal(prev_style, style) => {
current_text.push(*ch);
}
_ => {
if !current_text.is_empty() {
if let Some(prev_style) = &current_style {
spans.push(Span::styled(
current_text.clone(),
prev_style.to_style(base_color),
));
}
}
current_text = ch.to_string();
current_style = Some(style.clone());
}
}
}
// Добавляем последний span
if !current_text.is_empty() {
if let Some(style) = current_style {
spans.push(Span::styled(current_text, style.to_style(base_color)));
}
}
if spans.is_empty() {
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
}
spans
}
/// Фильтрует и корректирует entities для подстроки
///
/// Используется для правильного отображения форматирования при переносе текста.
///
/// # Аргументы
///
/// * `entities` - Исходный массив entities
/// * `start` - Начальная позиция подстроки (в символах)
/// * `length` - Длина подстроки (в символах)
///
/// # Возвращает
///
/// Новый массив entities с откорректированными offset и length
/// Корректирует offset entities для подстроки текста.
///
/// Используется при обрезке текста (например, для preview) для сохранения
/// корректных позиций форматирования.
///
/// # Arguments
///
/// * `entities` - Исходный массив entities
/// * `start` - Начальная позиция подстроки (в символах)
/// * `length` - Длина подстроки (в символах)
///
/// # Returns
///
/// Новый массив entities с скорректированными offset для подстроки.
///
/// # Examples
///
/// ```ignore
/// let text = "Hello **world** test";
/// let substring = &text[0..15]; // "Hello **world**"
/// let adjusted = adjust_entities_for_substring(&entities, 0, 15);
/// ```
pub fn adjust_entities_for_substring(
entities: &[TextEntity],
start: usize,
length: usize,
) -> Vec<TextEntity> {
let start = start as i32;
let end = start + length as i32;
entities
.iter()
.filter_map(|e| {
let e_start = e.offset;
let e_end = e.offset + e.length;
// Проверяем пересечение с нашей подстрокой
if e_end <= start || e_start >= end {
return None;
}
// Вычисляем пересечение
let new_start = (e_start - start).max(0);
let new_end = (e_end - start).min(length as i32);
if new_end > new_start {
Some(TextEntity {
offset: new_start,
length: new_end - new_start,
r#type: e.r#type.clone(),
})
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_text_no_entities() {
let text = "Hello, world!";
let entities = vec![];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Hello, world!");
}
#[test]
fn test_format_text_with_bold() {
let text = "Hello";
let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content, "Hello");
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn test_adjust_entities_full_overlap() {
let entities = vec![TextEntity {
offset: 0,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 1);
assert_eq!(adjusted[0].offset, 0);
assert_eq!(adjusted[0].length, 10);
}
#[test]
fn test_adjust_entities_partial_overlap() {
let entities = vec![TextEntity {
offset: 5,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 1);
assert_eq!(adjusted[0].offset, 5);
assert_eq!(adjusted[0].length, 5); // Обрезано до конца подстроки
}
#[test]
fn test_adjust_entities_no_overlap() {
let entities = vec![TextEntity {
offset: 20,
length: 10,
r#type: TextEntityType::Bold,
}];
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
assert_eq!(adjusted.len(), 0); // Нет пересечений
}
}

View File

@@ -0,0 +1,109 @@
use crate::app::App;
use crate::tdlib::{AuthState, TdClientTrait};
use crate::utils::{is_non_empty, with_timeout_msg};
use crossterm::event::KeyCode;
use std::time::Duration;
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
match &app.td_client.auth_state() {
AuthState::WaitPhoneNumber => match key_code {
KeyCode::Char(c) => {
app.phone_input_mut().push(c);
app.error_message = None;
}
KeyCode::Backspace => {
app.phone_input_mut().pop();
app.error_message = None;
}
KeyCode::Enter => {
if is_non_empty(app.phone_input()) {
app.status_message = Some("Отправка номера...".to_string());
match with_timeout_msg(
Duration::from_secs(10),
app.td_client
.send_phone_number(app.phone_input().to_string()),
"Таймаут отправки номера",
)
.await
{
Ok(_) => {
app.error_message = None;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
}
_ => {}
},
AuthState::WaitCode => match key_code {
KeyCode::Char(c) if c.is_numeric() => {
app.code_input_mut().push(c);
app.error_message = None;
}
KeyCode::Backspace => {
app.code_input_mut().pop();
app.error_message = None;
}
KeyCode::Enter => {
if is_non_empty(app.code_input()) {
app.status_message = Some("Проверка кода...".to_string());
match with_timeout_msg(
Duration::from_secs(10),
app.td_client.send_code(app.code_input().to_string()),
"Таймаут проверки кода",
)
.await
{
Ok(_) => {
app.error_message = None;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
}
_ => {}
},
AuthState::WaitPassword => match key_code {
KeyCode::Char(c) => {
app.password_input_mut().push(c);
app.error_message = None;
}
KeyCode::Backspace => {
app.password_input_mut().pop();
app.error_message = None;
}
KeyCode::Enter => {
if is_non_empty(app.password_input()) {
app.status_message = Some("Проверка пароля...".to_string());
match with_timeout_msg(
Duration::from_secs(10),
app.td_client
.send_password(app.password_input().to_string()),
"Таймаут проверки пароля",
)
.await
{
Ok(_) => {
app.error_message = None;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
}
_ => {}
},
_ => {}
}
}

View File

@@ -0,0 +1,458 @@
//! Chat input handlers
//!
//! Handles keyboard input when a chat is open, including:
//! - Message scrolling and navigation
//! - Message selection and actions
//! - Editing and sending messages
//! - Loading older messages
mod media;
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
/// Обработка режима выбора сообщения для действий
///
/// Обрабатывает:
/// - Навигацию по сообщениям (Up/Down)
/// - Удаление сообщения (d/в/Delete)
/// - Ответ на сообщение (r/к)
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveUp) => {
app.select_previous_message();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_message();
}
Some(crate::config::Command::DeleteMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
}
}
Some(crate::config::Command::EnterInsertMode) => {
app.input_mode = InputMode::Insert;
app.chat_state = crate::app::ChatState::Normal;
}
Some(crate::config::Command::ReplyMessage) => {
app.start_reply_to_selected();
app.input_mode = InputMode::Insert;
}
Some(crate::config::Command::ForwardMessage) => {
app.start_forward_selected();
}
Some(crate::config::Command::CopyMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let text = format_message_for_clipboard(&msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
}
Err(e) => {
app.error_message = Some(format!("Ошибка копирования: {}", e));
}
}
}
Some(crate::config::Command::ViewImage) => {
media::handle_view_or_play_media(app).await;
}
Some(crate::config::Command::TogglePlayback) => {
media::handle_toggle_voice_playback(app).await;
}
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
media::handle_voice_seek(app, 5.0);
}
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
media::handle_voice_seek(app, -5.0);
}
Some(crate::config::Command::ReactMessage) => {
let Some(chat_id) = app.selected_chat_id else {
app.error_message = Some("Чат не выбран".to_string());
return;
};
let Some(msg) = app.get_selected_message() else {
return;
};
let message_id = msg.id();
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.get_message_available_reactions(chat_id, message_id),
"Таймаут загрузки реакций",
)
.await
{
Ok(reactions) => {
let reactions: Vec<String> = reactions;
if reactions.is_empty() {
app.error_message =
Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
app.status_message = None;
app.needs_redraw = true;
}
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
_ => {}
}
}
/// Редактирование существующего сообщения
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()
.iter()
.any(|m| m.id() == msg_id);
if !msg_exists {
app.error_message =
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear();
app.cursor_position = 0;
return;
}
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования",
)
.await
{
Ok(mut edited_msg) => {
// Сохраняем reply_to из старого сообщения (если есть)
app.td_client.update_current_chat_messages(|messages| {
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to {
if edited_msg
.interactions
.reply_to
.as_ref()
.is_none_or(|r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply);
}
}
// Заменяем сообщение
messages[pos] = edited_msg;
}
});
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
app.message_input.clear();
app.cursor_position = 0;
app.chat_state = crate::app::ChatState::Normal;
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Отправка нового сообщения (с опциональным reply)
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
let reply_to_id = if app.is_replying() {
app.chat_state.selected_message_id()
} else {
None
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app
.get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(),
sender_name: m.sender_name().to_string(),
text: m.text().to_string(),
});
app.message_input.clear();
app.cursor_position = 0;
// Сбрасываем режим reply если он был активен
if app.is_replying() {
app.chat_state = crate::app::ChatState::Normal;
}
app.last_typing_sent = None;
// Отменяем typing status
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки",
)
.await
{
Ok(sent_msg) => {
// Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg);
// Сбрасываем скролл чтобы видеть новое сообщение
app.message_scroll_offset = 0;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Обработка клавиши Enter
///
/// Обрабатывает три сценария:
/// 1. В режиме выбора сообщения: начать редактирование
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
/// 3. В списке чатов: открыть выбранный чат
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
// Сценарий 1: Открытие чата из списка
if app.selected_chat_id.is_none() {
let prev_selected = app.selected_chat_id;
app.select_current_chat();
if app.selected_chat_id != prev_selected {
if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await;
}
}
return;
}
// Сценарий 2: Режим выбора сообщения - начать редактирование
if app.is_selecting_message() {
if app.start_editing_selected() {
app.input_mode = InputMode::Insert;
} else {
// Нельзя редактировать это сообщение
app.chat_state = crate::app::ChatState::Normal;
}
return;
}
// Сценарий 3: Отправка или редактирование сообщения
if !is_non_empty(&app.message_input) {
return;
}
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
let text = app.message_input.clone();
if app.is_editing() {
// Редактирование существующего сообщения
if let Some(msg_id) = app.chat_state.selected_message_id() {
edit_message(app, chat_id, msg_id, text).await;
}
} else {
// Отправка нового сообщения
send_new_message(app, chat_id, text).await;
}
}
/// Отправляет реакцию на выбранное сообщение
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Get selected reaction emoji
let Some(emoji) = app.get_selected_reaction().cloned() else {
return;
};
// Get selected message ID
let Some(message_id) = app.get_selected_message_for_reaction() else {
return;
};
// Get chat ID
let Some(chat_id) = app.selected_chat_id else {
return;
};
let message_id = MessageId::new(message_id);
app.status_message = Some("Отправка реакции...".to_string());
app.needs_redraw = true;
// Send reaction with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client
.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции",
)
.await;
// Handle result
match result {
Ok(_) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
/// Обработка ввода клавиатуры в открытом чате
///
/// Обрабатывает:
/// - Backspace/Delete: удаление символов относительно курсора
/// - Char: вставка символов в позицию курсора + typing status
/// - Left/Right/Home/End: навигация курсора
/// - Up/Down: скролл сообщений или начало режима выбора
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Backspace => {
// Удаляем символ слева от курсора
if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position - 1 {
new_input.push(*ch);
}
}
app.message_input = new_input;
app.cursor_position -= 1;
}
}
KeyCode::Delete => {
// Удаляем символ справа от курсора
let len = app.message_input.chars().count();
if app.cursor_position < len {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position {
new_input.push(*ch);
}
}
app.message_input = new_input;
}
}
KeyCode::Char(c) => {
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+I для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
return;
}
// Вставляем символ в позицию курсора
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i == app.cursor_position {
new_input.push(c);
}
new_input.push(*ch);
}
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
let should_send_typing = app
.last_typing_sent
.map(|t| t.elapsed().as_secs() >= 5)
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
}
}
KeyCode::Left => {
// Курсор влево
if app.cursor_position > 0 {
app.cursor_position -= 1;
}
}
KeyCode::Right => {
// Курсор вправо
let len = app.message_input.chars().count();
if app.cursor_position < len {
app.cursor_position += 1;
}
}
KeyCode::Home => {
// Курсор в начало
app.cursor_position = 0;
}
KeyCode::End => {
// Курсор в конец
app.cursor_position = app.message_input.chars().count();
}
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
KeyCode::Down => {
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
}
KeyCode::Up => {
// В Insert mode — только скролл
app.message_scroll_offset += 3;
load_older_messages_if_needed(app).await;
}
_ => {}
}
}

View File

@@ -0,0 +1,328 @@
//! Media actions for the open chat input handler.
use crate::app::methods::messages::MessageMethods;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use std::time::{Duration, Instant};
/// Обработка команды ViewImage — только фото.
pub(super) async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_photo() {
#[cfg(feature = "images")]
handle_view_image(app).await;
#[cfg(not(feature = "images"))]
{
app.status_message = Some("Просмотр изображений отключён".to_string());
}
} else {
app.status_message = Some("Сообщение не содержит фото".to_string());
}
}
/// Space: play/pause toggle для голосовых сообщений.
pub(super) async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::PlaybackStatus;
if let Some(ref mut playback) = app.playback_state {
if let Some(ref player) = app.audio_player {
match playback.status {
PlaybackStatus::Playing => {
player.pause();
playback.status = PlaybackStatus::Paused;
app.last_playback_tick = None;
app.status_message = Some("⏸ Пауза".to_string());
}
PlaybackStatus::Paused => {
let resume_pos = (playback.position - 1.0).max(0.0);
if player.resume_from(resume_pos).is_ok() {
playback.position = resume_pos;
} else {
player.resume();
}
playback.status = PlaybackStatus::Playing;
app.last_playback_tick = Some(Instant::now());
app.status_message = Some("▶ Воспроизведение".to_string());
}
_ => {}
}
app.needs_redraw = true;
}
return;
}
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_voice() {
handle_play_voice(app).await;
}
}
/// Seek голосового сообщения на delta секунд.
pub(super) fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
use crate::tdlib::PlaybackStatus;
let Some(ref mut playback) = app.playback_state else {
return;
};
let Some(ref player) = app.audio_player else {
return;
};
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
if was_playing || was_paused {
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
if was_playing {
if player.resume_from(new_position).is_ok() {
playback.position = new_position;
app.last_playback_tick = Some(Instant::now());
}
} else {
player.stop();
playback.position = new_position;
}
let arrow = if delta > 0.0 { "" } else { "" };
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
app.needs_redraw = true;
}
}
#[cfg(feature = "images")]
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::{ImageModalState, PhotoDownloadState};
if !app.config().images.show_images {
return;
}
let Some(msg) = app.get_selected_message() else {
return;
};
if !msg.has_photo() {
app.status_message = Some("Сообщение не содержит фото".to_string());
return;
}
let Some(photo) = msg.photo_info() else {
app.status_message = Some("Сообщение не содержит фото".to_string());
return;
};
let msg_id = msg.id();
let file_id = photo.file_id;
let photo_width = photo.width;
let photo_height = photo.height;
let download_state = photo.download_state.clone();
match download_state {
PhotoDownloadState::Downloaded(path) => {
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.needs_redraw = true;
}
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
app.pending_image_open = Some(crate::app::PendingImageOpen {
file_id,
message_id: msg_id,
photo_width,
photo_height,
});
app.status_message = Some("Загрузка фото...".to_string());
app.needs_redraw = true;
if app.photo_download_rx.is_none() {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
app.photo_download_rx = Some(rx);
let client_id = app.td_client.client_id();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(30), async {
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
.await
{
Ok(tdlib_rs::enums::File::File(f))
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
{
Ok(f.local.path)
}
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
}
})
.await;
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
let _ = tx.send((file_id, result));
});
}
}
PhotoDownloadState::Error(_) => {
app.status_message = Some("Повторная загрузка фото...".to_string());
app.needs_redraw = true;
match app.td_client.download_file(file_id).await {
Ok(path) => {
app.td_client.update_current_chat_messages(|messages| {
for msg in messages {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state =
PhotoDownloadState::Downloaded(path.clone());
break;
}
}
}
});
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.status_message = None;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
app.status_message = None;
}
}
}
}
}
async fn handle_play_voice_from_path<T: TdClientTrait>(
app: &mut App<T>,
path: &str,
voice: &crate::tdlib::VoiceInfo,
msg: &crate::tdlib::MessageInfo,
) {
use crate::tdlib::{PlaybackState, PlaybackStatus};
if let Some(ref player) = app.audio_player {
match player.play(path) {
Ok(_) => {
app.playback_state = Some(PlaybackState {
message_id: msg.id(),
status: PlaybackStatus::Playing,
position: 0.0,
duration: voice.duration as f32,
volume: player.volume(),
});
app.last_playback_tick = Some(Instant::now());
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
}
}
} else {
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
}
}
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::VoiceDownloadState;
let Some(msg) = app.get_selected_message() else {
return;
};
if !msg.has_voice() {
return;
}
let Some(voice) = msg.voice_info() else {
app.status_message = Some("Сообщение не содержит голосовое".to_string());
return;
};
let file_id = voice.file_id;
match &voice.download_state {
VoiceDownloadState::Downloaded(path) => {
use std::path::Path;
let audio_path = if Path::new(path).exists() {
path.clone()
} else {
let with_oga = format!("{}.oga", path);
if Path::new(&with_oga).exists() {
with_oga
} else {
if let Some(parent) = Path::new(path).parent() {
if let Some(stem) = Path::new(path).file_name() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let entry_name = entry.file_name();
if entry_name
.to_string_lossy()
.starts_with(&stem.to_string_lossy().to_string())
{
let found_path = entry.path().to_string_lossy().to_string();
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(
&file_id.to_string(),
Path::new(&found_path),
);
}
return handle_play_voice_from_path(
app,
&found_path,
voice,
&msg,
)
.await;
}
}
}
}
}
app.error_message = Some(format!("Файл не найден: {}", path));
return;
}
};
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
}
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
}
VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string());
}
VoiceDownloadState::NotDownloaded => {
let cache_key = file_id.to_string();
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
let path_str = cached_path.to_string_lossy().to_string();
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
return;
}
app.status_message = Some("Загрузка голосового...".to_string());
match app.td_client.download_voice_note(file_id).await {
Ok(path) => {
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&cache_key, std::path::Path::new(&path));
}
handle_play_voice_from_path(app, &path, voice, &msg).await;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
VoiceDownloadState::Error(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}

View File

@@ -0,0 +1,76 @@
//! Chat list input handlers
//!
//! Handles keyboard input for the chat list view, including:
//! - Navigation between chats
//! - Folder selection
//! - Opening chats
use crate::app::methods::navigation::NavigationMethods;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::utils::with_timeout;
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка навигации в списке чатов
///
/// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами
/// - Цифры 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>,
) {
match command {
Some(crate::config::Command::MoveDown) => {
app.next_chat();
}
Some(crate::config::Command::MoveUp) => {
app.previous_chat();
}
Some(crate::config::Command::SelectFolder1) => {
app.selected_folder_id = None;
app.chat_list_state.select(Some(0));
}
Some(crate::config::Command::SelectFolder2) => {
select_folder(app, 0).await;
}
Some(crate::config::Command::SelectFolder3) => {
select_folder(app, 1).await;
}
Some(crate::config::Command::SelectFolder4) => {
select_folder(app, 2).await;
}
Some(crate::config::Command::SelectFolder5) => {
select_folder(app, 3).await;
}
Some(crate::config::Command::SelectFolder6) => {
select_folder(app, 4).await;
}
Some(crate::config::Command::SelectFolder7) => {
select_folder(app, 5).await;
}
Some(crate::config::Command::SelectFolder8) => {
select_folder(app, 6).await;
}
Some(crate::config::Command::SelectFolder9) => {
select_folder(app, 7).await;
}
_ => {}
}
}
/// Выбирает папку по индексу и загружает её чаты
pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize) {
if let Some(folder) = app.td_client.folders().get(folder_idx) {
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
.await;
app.status_message = None;
app.chat_list_state.select(Some(0));
}
}

View File

@@ -0,0 +1,295 @@
//! Chat loading logic — all three phases of message loading
//!
//! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages)
//! - Phase 2: `process_pending_chat_init` — starts background tasks (reply info, photos)
//! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::InputMode;
use crate::app::{App, ChatInitEvent};
use crate::tdlib::message_conversion::{extract_content_text, extract_sender_name};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_msg};
use std::time::Duration;
use tokio::sync::mpsc::error::TryRecvError;
/// Открывает чат и загружает последние сообщения (быстро).
///
/// Загружает только 50 последних сообщений для мгновенного отображения.
/// Фоновые задачи (reply info, photos) откладываются в `pending_chat_init`
/// и стартуют после первого redraw.
///
/// При ошибке устанавливает error_message и очищает status_message.
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
app.status_message = Some("Загрузка сообщений...".to_string());
app.message_scroll_offset = 0;
// Загружаем только 50 последних сообщений (один запрос к TDLib)
match with_timeout_msg(
Duration::from_secs(10),
app.td_client.get_chat_history(ChatId::new(chat_id), 50),
"Таймаут загрузки сообщений",
)
.await
{
Ok(messages) => {
// Собираем ID всех входящих сообщений для отметки как прочитанные
let incoming_message_ids: Vec<MessageId> = messages
.iter()
.filter(|msg| !msg.is_outgoing())
.map(|msg| msg.id())
.collect();
// Сохраняем загруженные сообщения
app.td_client.set_current_chat_messages(messages);
// Добавляем входящие сообщения в очередь для отметки как прочитанные
if !incoming_message_ids.is_empty() {
app.td_client
.enqueue_pending_view_messages(ChatId::new(chat_id), incoming_message_ids);
}
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
// Это предотвращает race condition с Update::NewMessage
app.td_client
.set_current_chat_id(Some(ChatId::new(chat_id)));
// Загружаем черновик (локальная операция, мгновенно)
app.load_draft();
// Показываем чат СРАЗУ
app.status_message = None;
app.input_mode = InputMode::Normal;
app.start_message_selection();
// Фоновые задачи (reply info, photos) — после первого redraw
app.pending_chat_init = Some(ChatId::new(chat_id));
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
/// Запускает фоновую инициализацию после открытия чата.
///
/// Вызывается после первого redraw после `open_chat_and_load_data`.
/// Не блокирует UI loop: TDLib запросы выполняются в отдельных Tokio tasks,
/// а готовые результаты применяются через `process_chat_init_events`.
pub fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
app.chat_init_rx = None;
let mut reply_message_ids: Vec<MessageId> = app
.td_client
.current_chat_messages()
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
reply_message_ids.sort_unstable();
reply_message_ids.dedup();
if !reply_message_ids.is_empty() {
let client_id = app.td_client.client_id();
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ChatInitEvent>();
app.chat_init_rx = Some(rx);
for message_id in reply_message_ids {
let tx = tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(5), async {
let Ok(original_msg_enum) = tdlib_rs::functions::get_message(
chat_id.as_i64(),
message_id.as_i64(),
client_id,
)
.await
else {
return None;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let sender_name = extract_sender_name(&original_msg, client_id).await;
let text: String = extract_content_text(&original_msg)
.chars()
.take(50)
.collect();
Some((sender_name, text))
})
.await
.ok()
.flatten();
if let Some((sender_name, text)) = result {
let _ = tx.send(ChatInitEvent::ReplyInfoLoaded {
chat_id,
message_id,
sender_name,
text,
});
}
});
}
}
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
#[cfg(feature = "images")]
{
use crate::tdlib::PhotoDownloadState;
if app.config().images.auto_download_images && app.config().images.show_images {
let photo_file_ids: Vec<i32> = app
.td_client
.current_chat_messages()
.iter()
.rev()
.take(5)
.filter_map(|msg| {
msg.photo_info().and_then(|p| {
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
.then_some(p.file_id)
})
})
.collect();
if !photo_file_ids.is_empty() {
let client_id = app.td_client.client_id();
let (tx, rx) =
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
app.photo_download_rx = Some(rx);
for file_id in photo_file_ids {
let tx = tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(5), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
{
Ok(tdlib_rs::enums::File::File(file))
if file.local.is_downloading_completed
&& !file.local.path.is_empty() =>
{
Ok(file.local.path)
}
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
}
})
.await;
let result = match result {
Ok(r) => r,
Err(_) => Err("Таймаут загрузки".to_string()),
};
let _ = tx.send((file_id, result));
});
}
}
}
}
}
/// Применяет готовые результаты фоновой инициализации чата.
pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
let mut events = Vec::new();
let mut disconnected = false;
if let Some(rx) = app.chat_init_rx.as_mut() {
loop {
match rx.try_recv() {
Ok(event) => events.push(event),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
if disconnected {
app.chat_init_rx = None;
}
for event in events {
match event {
ChatInitEvent::ReplyInfoLoaded { chat_id, message_id, sender_name, text } => {
if app.td_client.current_chat_id() != Some(chat_id) {
continue;
}
let mut changed = false;
app.td_client.update_current_chat_messages(|messages| {
for msg in messages {
let Some(reply) = msg.interactions.reply_to.as_mut() else {
continue;
};
if reply.message_id == message_id {
reply.sender_name = sender_name.clone();
reply.text = text.clone();
changed = true;
}
}
});
if changed {
app.needs_redraw = true;
}
}
}
}
}
/// Подгружает старые сообщения если скролл близко к верху
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
// Check if there are messages to load from
if app.td_client.current_chat_messages().is_empty() {
return;
}
// Get the oldest message ID
let oldest_msg_id = app
.td_client
.current_chat_messages()
.first()
.map(|m| m.id())
.unwrap_or(MessageId::new(0));
// Get current chat ID
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
// Check if scroll is near the top
let message_count = app.td_client.current_chat_messages().len();
if app.message_scroll_offset <= message_count.saturating_sub(10) {
return;
}
// Load older messages with timeout
let Ok(older) = with_timeout(
Duration::from_secs(3),
app.td_client
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
)
.await
else {
return;
};
// Add older messages to the beginning if any were loaded
if !older.is_empty() {
app.td_client.update_current_chat_messages(|messages| {
messages.splice(0..0, older);
});
}
}

View File

@@ -0,0 +1,101 @@
//! Clipboard operations for copying messages
use crate::tdlib::MessageInfo;
/// Копирует текст в системный буфер обмена
#[cfg(feature = "clipboard")]
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
use arboard::Clipboard;
let mut clipboard =
Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
clipboard
.set_text(text)
.map_err(|e| format!("Не удалось скопировать: {}", e))?;
Ok(())
}
/// Заглушка для copy_to_clipboard когда feature "clipboard" выключена
#[cfg(not(feature = "clipboard"))]
pub fn copy_to_clipboard(_text: &str) -> Result<(), String> {
Err("Копирование в буфер обмена недоступно (требуется feature 'clipboard')".to_string())
}
/// Форматирует сообщение для копирования с контекстом
pub fn format_message_for_clipboard(msg: &MessageInfo) -> String {
let mut result = String::new();
// Добавляем forward контекст если есть
if let Some(forward) = msg.forward_from() {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
}
// Добавляем reply контекст если есть
if let Some(reply) = msg.reply_to() {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
}
// Добавляем основной текст с markdown форматированием
result.push_str(&convert_entities_to_markdown(msg.text(), msg.entities()));
result
}
/// Конвертирует текст с entities в markdown
fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String {
use tdlib_rs::enums::TextEntityType;
if entities.is_empty() {
return text.to_string();
}
// Создаём вектор символов для работы с unicode
let chars: Vec<char> = text.chars().collect();
let mut result = String::new();
let mut i = 0;
while i < chars.len() {
// Ищем entity, который начинается в текущей позиции
let mut entity_found = false;
for entity in entities {
if entity.offset as usize == i {
entity_found = true;
let end = (entity.offset + entity.length) as usize;
let entity_text: String = chars[i..end.min(chars.len())].iter().collect();
// Применяем форматирование в зависимости от типа
let formatted = match &entity.r#type {
TextEntityType::Bold => format!("**{}**", entity_text),
TextEntityType::Italic => format!("*{}*", entity_text),
TextEntityType::Underline => format!("__{}__", entity_text),
TextEntityType::Strikethrough => format!("~~{}~~", entity_text),
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
format!("`{}`", entity_text)
}
TextEntityType::TextUrl(url_info) => {
format!("[{}]({})", entity_text, url_info.url)
}
TextEntityType::Url => format!("<{}>", entity_text),
TextEntityType::Mention | TextEntityType::MentionName(_) => {
format!("@{}", entity_text.trim_start_matches('@'))
}
TextEntityType::Spoiler => format!("||{}||", entity_text),
_ => entity_text,
};
result.push_str(&formatted);
i = end;
break;
}
}
if !entity_found {
result.push(chars[i]);
i += 1;
}
}
result
}

View File

@@ -0,0 +1,85 @@
//! Compose input handlers
//!
//! Handles text input and message composition, including:
//! - Forward mode
//! - Reply mode
//! - Edit mode
//! - Cursor movement and text editing
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::with_timeout_msg;
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима выбора чата для пересылки сообщения
///
/// Обрабатывает:
/// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc)
pub async fn handle_forward_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_forward();
}
Some(crate::config::Command::SubmitMessage) => {
forward_selected_message(app).await;
app.cancel_forward();
}
Some(crate::config::Command::MoveDown) => {
app.next_chat();
}
Some(crate::config::Command::MoveUp) => {
app.previous_chat();
}
_ => {}
}
}
/// Пересылает выбранное сообщение в выбранный чат
pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
// Get all required IDs with early returns
let filtered = app.get_filtered_chats();
let Some(i) = app.chat_list_state.selected() else {
return;
};
let Some(chat) = filtered.get(i) else {
return;
};
let to_chat_id = chat.id;
let Some(msg_id) = app.chat_state.selected_message_id() else {
return;
};
let Some(from_chat_id) = app.get_selected_chat_id() else {
return;
};
// Forward the message with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
"Таймаут пересылки",
)
.await;
// Handle result
match result {
Ok(_) => {
app.status_message = Some("Сообщение переслано".to_string());
}
Err(e) => {
app.error_message = Some(e);
}
}
}

View File

@@ -0,0 +1,102 @@
//! Global commands that work from any screen
//!
//! Handles Ctrl+ combinations:
//! - Ctrl+R: Refresh chats
//! - Ctrl+S: Start search
//! - Ctrl+P: View pinned messages
//! - Ctrl+F: Search messages in chat
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::Duration;
/// Обрабатывает глобальные команды (Ctrl+ combinations).
///
/// # Returns
///
/// `true` если команда была обработана, `false` если нет
pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) -> bool {
let command = app.get_command(key);
match command {
Some(crate::config::Command::OpenSearch) => {
// Ctrl+S - начать поиск (только если чат не открыт)
if app.selected_chat_id.is_none() {
app.start_search();
}
true
}
Some(crate::config::Command::OpenSearchInChat) => {
// Ctrl+F - поиск по сообщениям в открытом чате
if app.selected_chat_id.is_some()
&& !app.is_pinned_mode()
&& !app.is_message_search_mode()
{
app.enter_message_search_mode();
}
true
}
_ => {
// Проверяем специальные комбинации, которых нет в Command enum
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string());
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления
app.notification_manager
.sync_muted_chats(app.td_client.chats());
app.status_message = None;
true
}
KeyCode::Char('p') if has_ctrl => {
// Ctrl+P - режим просмотра закреплённых сообщений
handle_pinned_messages(app).await;
true
}
KeyCode::Char('a') if has_ctrl => {
// Ctrl+A - переключение аккаунтов
app.open_account_switcher();
true
}
_ => false,
}
}
}
}
/// Обрабатывает загрузку и отображение закреплённых сообщений
async fn handle_pinned_messages<T: TdClientTrait>(app: &mut App<T>) {
if app.selected_chat_id.is_some() && !app.is_pinned_mode() {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка закреплённых...".to_string());
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.get_pinned_messages(ChatId::new(chat_id)),
"Таймаут загрузки",
)
.await
{
Ok(messages) => {
let messages: Vec<crate::tdlib::MessageInfo> = messages;
if messages.is_empty() {
app.status_message = Some("Нет закреплённых сообщений".to_string());
} else {
app.enter_pinned_mode(messages);
app.status_message = None;
}
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
//! Input handlers organized by functionality
//!
//! This module contains handlers for different input contexts:
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.)
//! - clipboard: Clipboard operations
//! - profile: Profile helper functions
//! - chat: Keyboard input handling for open chat view
//! - chat_list: Navigation and interaction in the chat list
//! - chat_loader: All phases of chat message loading
//! - compose: Text input, editing, and message composition
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
//! - search: Search functionality (chat search, message search)
pub mod chat;
pub mod chat_list;
pub mod chat_loader;
pub mod clipboard;
pub mod compose;
pub mod global;
pub mod modal;
pub mod profile;
pub mod search;
pub use chat_loader::{process_chat_init_events, process_pending_chat_init};
pub use clipboard::*;
pub use global::*;
pub use profile::get_available_actions_count;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::MessageId;
/// Скроллит к сообщению по его ID в текущем чате
pub fn scroll_to_message<T: TdClientTrait>(app: &mut App<T>, message_id: MessageId) {
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == message_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
}

View File

@@ -0,0 +1,13 @@
//! Modal dialog handlers.
mod account;
mod delete;
mod pinned;
mod profile;
mod reactions;
pub use account::handle_account_switcher;
pub use delete::handle_delete_confirmation;
pub use pinned::handle_pinned_mode;
pub use profile::{handle_profile_mode, handle_profile_open};
pub use reactions::handle_reaction_picker_mode;

View File

@@ -0,0 +1,76 @@
use crate::app::{AccountSwitcherState, App};
use crate::tdlib::TdClientTrait;
use crossterm::event::{KeyCode, KeyEvent};
/// Обработка ввода в модалке переключения аккаунтов.
pub async fn handle_account_switcher<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
let Some(state) = &app.account_switcher else {
return;
};
match state {
AccountSwitcherState::SelectAccount { .. } => match command {
Some(crate::config::Command::MoveUp) => {
app.account_switcher_select_prev();
}
Some(crate::config::Command::MoveDown) => {
app.account_switcher_select_next();
}
Some(crate::config::Command::SubmitMessage) => {
app.account_switcher_confirm();
}
Some(crate::config::Command::Cancel) => {
app.close_account_switcher();
}
_ => match key.code {
KeyCode::Char('a') | KeyCode::Char('ф') => {
app.account_switcher_start_add();
}
_ => {}
},
},
AccountSwitcherState::AddAccount { .. } => match key.code {
KeyCode::Esc => {
app.account_switcher_back();
}
KeyCode::Enter => {
app.account_switcher_confirm_add();
}
KeyCode::Backspace => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
if *cursor_position > 0 {
let mut chars: Vec<char> = name_input.chars().collect();
chars.remove(*cursor_position - 1);
*name_input = chars.into_iter().collect();
*cursor_position -= 1;
*error = None;
}
}
}
KeyCode::Char(c) => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
let mut chars: Vec<char> = name_input.chars().collect();
chars.insert(*cursor_position, c);
*name_input = chars.into_iter().collect();
*cursor_position += 1;
*error = None;
}
}
_ => {}
},
}
}

View File

@@ -0,0 +1,52 @@
use crate::app::{App, ChatState};
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка модалки подтверждения удаления сообщения.
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match handle_yes_no(key.code) {
Some(true) => {
if let Some(msg_id) = app.chat_state.selected_message_id() {
if let Some(chat_id) = app.get_selected_chat_id() {
let can_delete_for_all = app
.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == msg_id)
.map(|m| m.can_be_deleted_for_all_users())
.unwrap_or(false);
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.delete_messages(
ChatId::new(chat_id),
vec![msg_id],
can_delete_for_all,
),
"Таймаут удаления",
)
.await
{
Ok(_) => {
app.td_client.update_current_chat_messages(|messages| {
messages.retain(|m| m.id() != msg_id);
});
app.chat_state = ChatState::Normal;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
}
app.chat_state = ChatState::Normal;
}
Some(false) => {
app.chat_state = ChatState::Normal;
}
None => {}
}
}

View File

@@ -0,0 +1,32 @@
use crate::app::methods::modal::ModalMethods;
use crate::app::App;
use crate::input::handlers::scroll_to_message;
use crate::tdlib::TdClientTrait;
use crate::types::MessageId;
use crossterm::event::KeyEvent;
/// Обработка режима просмотра закреплённых сообщений.
pub async fn handle_pinned_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.exit_pinned_mode();
}
Some(crate::config::Command::MoveUp) => {
app.select_previous_pinned();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_pinned();
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_pinned_id() {
scroll_to_message(app, MessageId::new(msg_id));
app.exit_pinned_mode();
}
}
_ => {}
}
}

View File

@@ -0,0 +1,136 @@
use crate::app::methods::modal::ModalMethods;
use crate::app::methods::navigation::NavigationMethods;
use crate::app::App;
use crate::input::handlers::get_available_actions_count;
use crate::tdlib::TdClientTrait;
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима профиля пользователя/чата.
pub async fn handle_profile_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
match handle_yes_no(key.code) {
Some(true) => {
if confirmation_step == 1 {
app.show_leave_group_final_confirmation();
} else if confirmation_step == 2 {
if let Some(chat_id) = app.selected_chat_id {
let leave_result = app.td_client.leave_chat(chat_id).await;
match leave_result {
Ok(_) => {
app.status_message = Some("Вы вышли из группы".to_string());
app.exit_profile_mode();
app.close_chat();
}
Err(e) => {
app.error_message = Some(e);
app.cancel_leave_group();
}
}
}
}
}
Some(false) => {
app.cancel_leave_group();
}
None => {}
}
return;
}
match command {
Some(crate::config::Command::Cancel) => {
app.exit_profile_mode();
}
Some(crate::config::Command::MoveUp) => {
app.select_previous_profile_action();
}
Some(crate::config::Command::MoveDown) => {
if let Some(profile) = app.get_profile_info() {
let max_actions = get_available_actions_count(profile);
app.select_next_profile_action(max_actions);
}
}
Some(crate::config::Command::SubmitMessage) => {
let Some(profile) = app.get_profile_info() else {
return;
};
let actions = get_available_actions_count(profile);
let action_index = app.get_selected_profile_action().unwrap_or(0);
if action_index >= actions {
return;
}
let mut current_idx = 0;
if let Some(username) = &profile.username {
if action_index == current_idx {
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
#[cfg(feature = "url-open")]
{
match open::that(&url) {
Ok(_) => {
app.status_message = Some(format!("Открыто: {}", url));
}
Err(e) => {
app.error_message =
Some(format!("Ошибка открытия браузера: {}", e));
}
}
}
#[cfg(not(feature = "url-open"))]
{
app.error_message = Some(
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
);
}
return;
}
current_idx += 1;
}
if action_index == current_idx {
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
return;
}
current_idx += 1;
if profile.is_group && action_index == current_idx {
app.show_leave_group_confirmation();
}
}
_ => {}
}
}
/// Обработка Ctrl+I для открытия профиля чата/пользователя.
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
let Some(chat_id) = app.selected_chat_id else {
return;
};
app.status_message = Some("Загрузка профиля...".to_string());
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.get_profile_info(chat_id),
"Таймаут загрузки профиля",
)
.await
{
Ok(profile) => {
app.enter_profile_mode(profile);
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}

View File

@@ -0,0 +1,54 @@
use crate::app::methods::modal::ModalMethods;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обработка режима выбора реакции (emoji picker).
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveLeft) => {
app.select_previous_reaction();
app.needs_redraw = true;
}
Some(crate::config::Command::MoveRight) => {
app.select_next_reaction();
app.needs_redraw = true;
}
Some(crate::config::Command::MoveUp) => {
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
&mut app.chat_state
{
if *selected_index >= 8 {
*selected_index = selected_index.saturating_sub(8);
app.needs_redraw = true;
}
}
}
Some(crate::config::Command::MoveDown) => {
if let crate::app::ChatState::ReactionPicker {
selected_index,
available_reactions,
..
} = &mut app.chat_state
{
let new_index = *selected_index + 8;
if new_index < available_reactions.len() {
*selected_index = new_index;
app.needs_redraw = true;
}
}
}
Some(crate::config::Command::SubmitMessage) => {
crate::input::handlers::chat::send_reaction(app).await;
}
Some(crate::config::Command::Cancel) => {
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
_ => {}
}
}

View File

@@ -0,0 +1,21 @@
//! Profile mode helper functions
/// Возвращает количество доступных действий в профиле
pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
let mut count = 0;
// Всегда есть: назад, посмотреть фото
count += 2;
// Уведомления (только для групп)
if profile.is_group {
count += 1;
}
// Выход из группы (только для групп)
if profile.is_group {
count += 1;
}
count
}

View File

@@ -0,0 +1,136 @@
//! Search input handlers
//!
//! Handles keyboard input for search functionality, including:
//! - Chat list search mode
//! - Message search mode
//! - Search query input
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout;
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
use super::chat_loader::open_chat_and_load_data;
use super::scroll_to_message;
/// Обработка режима поиска по чатам
///
/// Обрабатывает:
/// - Редактирование поискового запроса (Backspace, Char)
/// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter)
/// - Отмену поиска (Esc)
pub async fn handle_chat_search_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_search();
}
Some(crate::config::Command::SubmitMessage) => {
app.select_filtered_chat();
if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await;
}
}
Some(crate::config::Command::MoveDown) => {
app.next_filtered_chat();
}
Some(crate::config::Command::MoveUp) => {
app.previous_filtered_chat();
}
_ => match key.code {
KeyCode::Backspace => {
app.search_query.pop();
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)
/// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (Esc)
pub async fn handle_message_search_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.exit_message_search_mode();
}
Some(crate::config::Command::MoveUp) => {
app.select_previous_search_result();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_search_result();
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_search_result_id() {
scroll_to_message(app, MessageId::new(msg_id));
app.exit_message_search_mode();
}
}
_ => match key.code {
KeyCode::Char('N') => {
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;
}
_ => {}
},
}
}
/// Выполняет поиск по сообщениям с обновлением результатов
pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &str) {
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
if query.is_empty() {
app.set_search_results(Vec::new());
return;
}
if let Ok(results) = with_timeout(
Duration::from_secs(3),
app.td_client.search_messages(ChatId::new(chat_id), query),
)
.await
{
app.set_search_results(results);
}
}

View File

@@ -0,0 +1,327 @@
//! Main screen input router.
//!
//! Dispatches keyboard events to specialized handlers based on current app mode.
//! 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::InputMode;
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,
modal::{
handle_account_switcher, handle_delete_confirmation, handle_pinned_mode,
handle_profile_mode, handle_profile_open, handle_reaction_picker_mode,
},
search::{handle_chat_search_mode, handle_message_search_mode},
};
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обработка клавиши Esc в Normal mode
///
/// Закрывает чат с сохранением черновика
async fn handle_escape_normal<T: TdClientTrait>(app: &mut App<T>) {
// Закрываем модальное окно изображения если открыто
#[cfg(feature = "images")]
if app.image_modal.is_some() {
app.image_modal = None;
app.needs_redraw = true;
return;
}
// Закрытие чата с сохранением черновика
let Some(chat_id) = app.selected_chat_id else {
return;
};
// Сохраняем черновик если есть текст в инпуте
if !app.message_input.is_empty() {
let draft_text = app.message_input.clone();
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else {
// Очищаем черновик если инпут пустой
let _ = app
.td_client
.set_draft_message(chat_id, String::new())
.await;
}
app.close_chat();
}
/// Обработка клавиши Esc в Insert mode
///
/// Отменяет Reply/Editing и возвращает в Normal + MessageSelection
fn handle_escape_insert<T: TdClientTrait>(app: &mut App<T>) {
if app.is_editing() {
app.cancel_editing();
}
if app.is_replying() {
app.cancel_reply();
}
app.input_mode = InputMode::Normal;
app.start_message_selection();
}
/// Главный обработчик ввода - роутер для всех режимов приложения
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
let command = app.get_command(key);
// 0. Account switcher (глобальный оверлей — highest priority)
if app.account_switcher.is_some() {
handle_account_switcher(app, key, command).await;
return;
}
// 1. Insert mode + чат открыт → только текст, Enter, Esc
// (Ctrl+C обрабатывается в main.rs до вызова router)
if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert {
// Модальные окна всё равно обрабатываем (image modal, delete confirmation etc.)
#[cfg(feature = "images")]
if app.image_modal.is_some() {
handle_image_modal_mode(app, key).await;
return;
}
if app.is_confirm_delete_shown() {
handle_delete_confirmation(app, key).await;
return;
}
if app.is_reaction_picker_mode() {
handle_reaction_picker_mode(app, key, command).await;
return;
}
if app.is_profile_mode() {
handle_profile_mode(app, key, command).await;
return;
}
if app.is_message_search_mode() {
handle_message_search_mode(app, key, command).await;
return;
}
if app.is_pinned_mode() {
handle_pinned_mode(app, key, command).await;
return;
}
if app.is_forwarding() {
handle_forward_mode(app, key, command).await;
return;
}
match command {
Some(crate::config::Command::Cancel) => {
handle_escape_insert(app);
return;
}
Some(crate::config::Command::SubmitMessage) => {
handle_enter_key(app).await;
return;
}
Some(crate::config::Command::DeleteWord) => {
// Ctrl+W → удалить слово
if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_pos = app.cursor_position;
// Пропускаем пробелы
while new_pos > 0 && chars[new_pos - 1] == ' ' {
new_pos -= 1;
}
// Пропускаем слово
while new_pos > 0 && chars[new_pos - 1] != ' ' {
new_pos -= 1;
}
let new_input: String = chars[..new_pos]
.iter()
.chain(chars[app.cursor_position..].iter())
.collect();
app.message_input = new_input;
app.cursor_position = new_pos;
}
return;
}
Some(crate::config::Command::MoveToStart) => {
app.cursor_position = 0;
return;
}
Some(crate::config::Command::MoveToEnd) => {
app.cursor_position = app.message_input.chars().count();
return;
}
_ => {}
}
// Весь остальной ввод → текст
handle_open_chat_keyboard_input(app, key).await;
return;
}
// 3. Глобальные команды (Ctrl+R, Ctrl+S, Ctrl+P, Ctrl+F)
if handle_global_commands(app, key).await {
return;
}
// 4. Модальное окно просмотра изображения
#[cfg(feature = "images")]
if app.image_modal.is_some() {
handle_image_modal_mode(app, key).await;
return;
}
// 5. Режим профиля
if app.is_profile_mode() {
handle_profile_mode(app, key, command).await;
return;
}
// 6. Режим поиска по сообщениям
if app.is_message_search_mode() {
handle_message_search_mode(app, key, command).await;
return;
}
// 7. Режим просмотра закреплённых сообщений
if app.is_pinned_mode() {
handle_pinned_mode(app, key, command).await;
return;
}
// 8. Обработка ввода в режиме выбора реакции
if app.is_reaction_picker_mode() {
handle_reaction_picker_mode(app, key, command).await;
return;
}
// 9. Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
handle_delete_confirmation(app, key).await;
return;
}
// 10. Режим выбора чата для пересылки
if app.is_forwarding() {
handle_forward_mode(app, key, command).await;
return;
}
// 11. Режим поиска чатов
if app.is_searching {
handle_chat_search_mode(app, key, command).await;
return;
}
// 12. Normal mode commands (Enter, Esc, Profile)
match command {
Some(crate::config::Command::SubmitMessage) => {
handle_enter_key(app).await;
return;
}
Some(crate::config::Command::Cancel) => {
handle_escape_normal(app).await;
return;
}
Some(crate::config::Command::OpenProfile) => {
if app.selected_chat_id.is_some() {
handle_profile_open(app).await;
return;
}
}
_ => {}
}
// 13. Normal mode в чате → MessageSelection
if app.selected_chat_id.is_some() {
// Auto-enter MessageSelection if not already in it
if !app.is_selecting_message() {
app.start_message_selection();
}
handle_message_selection(app, key, command).await;
} else {
// 14. Список чатов
handle_chat_list_navigation(app, key, command).await;
}
}
/// Обработка модального окна просмотра изображения
///
/// Hotkeys:
/// - Esc/q: закрыть модальное окно
/// - ←: предыдущее фото в чате
/// - →: следующее фото в чате
#[cfg(feature = "images")]
async fn handle_image_modal_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('й') => {
// Закрываем модальное окно
app.image_modal = None;
app.needs_redraw = true;
}
KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('р') => {
// Предыдущее фото в чате
navigate_to_adjacent_photo(app, Direction::Previous).await;
}
KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('д') => {
// Следующее фото в чате
navigate_to_adjacent_photo(app, Direction::Next).await;
}
_ => {}
}
}
#[cfg(feature = "images")]
enum Direction {
Previous,
Next,
}
/// Переключение на соседнее фото в чате
#[cfg(feature = "images")]
async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, direction: Direction) {
use crate::tdlib::PhotoDownloadState;
let Some(current_modal) = &app.image_modal else {
return;
};
let current_msg_id = current_modal.message_id;
let messages = app.td_client.current_chat_messages();
// Находим текущее сообщение
let Some(current_idx) = messages.iter().position(|m| m.id() == current_msg_id) else {
return;
};
// Ищем следующее/предыдущее сообщение с фото
let search_range: Box<dyn Iterator<Item = usize>> = match direction {
Direction::Previous => Box::new((0..current_idx).rev()),
Direction::Next => Box::new((current_idx + 1)..messages.len()),
};
for idx in search_range {
if let Some(photo) = messages[idx].photo_info() {
if let PhotoDownloadState::Downloaded(path) = &photo.download_state {
// Нашли фото - открываем его
app.image_modal = Some(crate::tdlib::ImageModalState {
message_id: messages[idx].id(),
photo_path: path.clone(),
photo_width: photo.width,
photo_height: photo.height,
});
app.needs_redraw = true;
return;
}
}
}
// Если не нашли фото - показываем сообщение
let msg = match direction {
Direction::Previous => "Нет предыдущих фото",
Direction::Next => "Нет следующих фото",
};
app.status_message = Some(msg.to_string());
}

View File

@@ -0,0 +1,10 @@
//! Input handling module.
//!
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
mod auth;
pub mod handlers;
mod main_input;
pub use auth::handle as handle_auth_input;
pub use main_input::handle as handle_main_input;

View File

@@ -0,0 +1,20 @@
//! tele-tui — TUI client for Telegram
//!
//! Library interface exposing modules for integration testing.
pub mod accounts;
pub mod app;
pub mod audio;
pub mod config;
pub mod constants;
pub mod formatting;
pub mod input;
#[cfg(feature = "images")]
pub mod media;
pub mod notifications;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
pub mod ui;
pub mod utils;
pub use tele_core::{message_grouping, tdlib, types};

540
crates/tele-tui/src/main.rs Normal file
View File

@@ -0,0 +1,540 @@
mod accounts;
mod app;
mod audio;
mod config;
mod constants;
mod formatting;
mod input;
#[cfg(feature = "images")]
mod media;
mod notifications;
mod ui;
mod utils;
pub use tele_core::{message_grouping, tdlib, types};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tdlib_rs::enums::Update;
use app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::handlers::{process_chat_init_events, process_pending_chat_init};
use input::{handle_auth_input, handle_main_input};
use tdlib::AuthState;
use utils::{disable_tdlib_logs, with_timeout_ignore};
/// Parses `--account <name>` from CLI arguments.
fn parse_account_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
if args[i] == "--account" && i + 1 < args.len() {
return Some(args[i + 1].clone());
}
i += 1;
}
None
}
#[tokio::main]
async fn main() -> Result<(), io::Error> {
// Загружаем переменные окружения из .env
let _ = dotenvy::dotenv();
// Инициализируем tracing subscriber для логирования
// Уровень логов можно настроить через переменную окружения RUST_LOG
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.init();
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
let config = config::Config::load();
// Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/
let accounts_config = accounts::load_or_create();
// Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg();
let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir(
account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
)
.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 ДО создания клиента
disable_tdlib_logs();
// Setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Ensure terminal restoration on panic
let panic_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
panic_hook(info);
}));
// Create app state with account-specific db_path
let mut app = App::new(config, db_path);
app.current_account_name = account_name;
app.account_lock = Some(account_lock);
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id();
let api_id = app.td_client.api_id;
let api_hash = app.td_client.api_hash.clone();
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move {
if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc
db_path_str, // database_directory
"".to_string(), // files_directory
"".to_string(), // database_encryption_key
true, // use_file_database
true, // use_chat_info_database
true, // use_message_database
false, // use_secret_chats
api_id,
api_hash,
"en".to_string(), // system_language_code
"Desktop".to_string(), // device_model
"".to_string(), // system_version
env!("CARGO_PKG_VERSION").to_string(), // application_version
client_id,
)
.await
{
tracing::error!("set_tdlib_parameters failed: {:?}", e);
}
});
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("Error: {:?}", err);
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
terminal: &mut Terminal<B>,
app: &mut App<T>,
) -> io::Result<()> {
// Флаг для остановки polling задачи
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Канал для передачи updates из polling задачи в main loop.
// client_id нужен при переключении аккаунтов: TDLib может ещё отдать
// updates от старого клиента после recreate_client().
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<(i32, Update)>();
// Запускаем polling TDLib receive() в отдельной задаче
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, client_id))) = result {
if update_tx.send((client_id, update)).is_err() {
break; // Канал закрыт, выходим
}
}
}
});
loop {
// Обрабатываем updates от TDLib из канала (неблокирующе)
let mut had_updates = false;
let active_client_id = app.td_client.client_id();
while let Ok((client_id, update)) = update_rx.try_recv() {
if client_id == active_client_id {
app.td_client.handle_update(update);
had_updates = true;
} else {
tracing::debug!(
"Ignoring TDLib update for inactive client_id={} (active={})",
client_id,
active_client_id
);
}
}
for event in app.td_client.drain_incoming_message_events() {
let _ = app.notification_manager.notify_new_message(
&event.chat,
&event.message,
&event.sender_name,
);
}
// Помечаем UI как требующий перерисовки если были обновления
if had_updates {
app.needs_redraw = true;
}
process_chat_init_events(app);
// Обрабатываем результаты фоновой загрузки фото
#[cfg(feature = "images")]
{
use crate::tdlib::PhotoDownloadState;
let mut got_photos = false;
if let Some(ref mut rx) = app.photo_download_rx {
while let Ok((file_id, result)) = rx.try_recv() {
let new_state = match result {
Ok(path) => PhotoDownloadState::Downloaded(path),
Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()),
};
app.td_client.update_current_chat_messages(|messages| {
for msg in messages {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = new_state;
got_photos = true;
break;
}
}
}
});
// Если это фото ждёт открытия в модалке — открываем
let pending_matches = app
.pending_image_open
.as_ref()
.map(|p| p.file_id == file_id)
.unwrap_or(false);
if pending_matches {
// Ищем путь из обновлённого состояния
let downloaded_path =
app.td_client.current_chat_messages().iter().find_map(|m| {
m.photo_info().and_then(|p| {
if p.file_id == file_id {
if let PhotoDownloadState::Downloaded(ref path) =
p.download_state
{
Some(path.clone())
} else {
None
}
} else {
None
}
})
});
if let (Some(path), Some(pending)) =
(downloaded_path, app.pending_image_open.take())
{
use crate::tdlib::ImageModalState;
app.image_modal = Some(ImageModalState {
message_id: pending.message_id,
photo_path: path,
photo_width: pending.photo_width,
photo_height: pending.photo_height,
});
app.status_message = None;
got_photos = true;
}
}
}
}
if got_photos {
app.needs_redraw = true;
}
}
// Очищаем устаревший typing status
if app.td_client.clear_stale_typing_status() {
app.needs_redraw = true;
}
// Обрабатываем очередь сообщений для отметки как прочитанных
if !app.td_client.pending_view_messages().is_empty() {
app.td_client.process_pending_view_messages().await;
}
// Обрабатываем очередь user_id для загрузки имён
if !app.td_client.pending_user_ids().is_empty() {
app.td_client.process_pending_user_ids().await;
}
// Обновляем состояние экрана на основе auth_state
let screen_changed = update_screen_state(app).await;
if screen_changed {
app.needs_redraw = true;
}
// Обновляем позицию воспроизведения голосового сообщения
{
let mut stop_playback = false;
if let Some(ref mut playback) = app.playback_state {
use crate::tdlib::PlaybackStatus;
match playback.status {
PlaybackStatus::Playing => {
let prev_second = playback.position as u32;
if let Some(last_tick) = app.last_playback_tick {
let delta = last_tick.elapsed().as_secs_f32();
playback.position += delta;
}
app.last_playback_tick = Some(std::time::Instant::now());
// Проверяем завершение воспроизведения
if playback.position >= playback.duration
|| app.audio_player.as_ref().is_some_and(|p| p.is_stopped())
{
stop_playback = true;
}
// Перерисовка только при смене секунды (не 60 FPS)
if playback.position as u32 != prev_second || stop_playback {
app.needs_redraw = true;
}
}
_ => {
app.last_playback_tick = None;
}
}
}
if stop_playback {
app.stop_playback();
app.last_playback_tick = None;
}
}
// Рендерим только если есть изменения
if app.needs_redraw {
terminal.draw(|f| ui::render(f, app))?;
app.needs_redraw = false;
}
// Используем poll с коротким таймаутом для быстрой реакции на ввод
// 16ms ≈ 60 FPS потенциально, но рендерим только при изменениях
if event::poll(Duration::from_millis(POLL_TIMEOUT_MS))? {
match event::read()? {
Event::Key(key) => {
// Global quit command
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
// Graceful shutdown
should_stop.store(true, Ordering::Relaxed);
// Останавливаем воспроизведение голосового (убиваем ffplay)
app.stop_playback();
// Закрываем TDLib клиент
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(());
}
// Ctrl+A opens account switcher from any screen
if key.code == KeyCode::Char('a')
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& app.account_switcher.is_none()
{
app.open_account_switcher();
} else if app.account_switcher.is_some() {
// Route to main input handler when account switcher is open
handle_main_input(app, key).await;
} else {
match app.screen {
AppScreen::Loading => {
// В состоянии загрузки игнорируем ввод
}
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
}
}
// Любой ввод требует перерисовки
app.needs_redraw = true;
}
Event::Resize(_, _) => {
// При изменении размера терминала нужна перерисовка
app.needs_redraw = true;
}
_ => {}
}
}
// Process pending chat initialization only after pending redraw is flushed.
// This guarantees the initial 50-message chat view is rendered before slower
// reply/photo initialization tasks start.
if !app.needs_redraw {
if let Some(chat_id) = app.pending_chat_init.take() {
process_pending_chat_init(app, chat_id);
}
}
// Check pending account switch
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
app.stop_playback();
// 2. Drop queued updates from the old client before recreating TDLib.
while update_rx.try_recv().is_ok() {}
// 3. Recreate client (closes old, creates new, inits TDLib params)
if let Err(e) = app.td_client.recreate_client(new_db_path).await {
app.error_message = Some(format!("Ошибка переключения: {}", e));
continue;
}
let notifications_cfg = app.config().notifications.clone();
app.notification_manager.configure(&notifications_cfg);
// 4. Reset app state
app.current_account_name = account_name.clone();
app.screen = AppScreen::Loading;
// 5. Persist selected account as default for next launch
let mut accounts_config = accounts::load_or_create();
accounts_config.default_account = account_name;
if let Err(e) = accounts::save(&accounts_config) {
tracing::warn!("Could not save default account: {}", e);
}
app.chats.clear();
app.selected_chat_id = None;
app.chat_state = Default::default();
app.input_mode = Default::default();
app.status_message = Some("Переключение аккаунта...".to_string());
app.error_message = None;
app.is_searching = false;
app.search_query.clear();
app.message_input.clear();
app.cursor_position = 0;
app.message_scroll_offset = 0;
app.pending_chat_init = None;
app.chat_init_rx = None;
#[cfg(feature = "images")]
{
app.photo_download_rx = None;
app.pending_image_open = None;
}
app.account_switcher = None;
app.needs_redraw = true;
}
}
}
/// Возвращает true если состояние изменилось и требуется перерисовка
async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> bool {
use utils::with_timeout_ignore;
let prev_screen = app.screen.clone();
let prev_status = app.status_message.clone();
let prev_error = app.error_message.clone();
let prev_chats_len = app.chats.len();
match &app.td_client.auth_state() {
AuthState::WaitTdlibParameters => {
app.screen = AppScreen::Loading;
app.status_message = Some("Инициализация TDLib...".to_string());
}
AuthState::WaitPhoneNumber | AuthState::WaitCode | AuthState::WaitPassword => {
app.screen = AppScreen::Auth;
app.is_loading = false;
}
AuthState::Ready => {
if prev_screen != AppScreen::Main {
app.screen = AppScreen::Main;
app.is_loading = true;
app.status_message = Some("Загрузка чатов...".to_string());
// Запрашиваем загрузку чатов с таймаутом (игнорируем ошибки)
with_timeout_ignore(Duration::from_secs(5), app.td_client.load_chats(50)).await;
}
// Синхронизируем чаты из td_client в app
if !app.td_client.chats().is_empty() {
app.chats = app.td_client.chats().to_vec();
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
app.chat_list_state.select(Some(0));
}
// Синхронизируем muted чаты для notifications
app.notification_manager
.sync_muted_chats(app.td_client.chats());
// Убираем статус загрузки когда чаты появились
if app.is_loading {
app.is_loading = false;
app.status_message = None;
}
}
}
AuthState::Closed => {
app.status_message = Some("Соединение закрыто".to_string());
}
AuthState::Error(e) => {
app.error_message = Some(e.clone());
}
}
// Проверяем, изменилось ли что-то
app.screen != prev_screen
|| app.status_message != prev_status
|| app.error_message != prev_error
|| app.chats.len() != prev_chats_len
}

View File

@@ -0,0 +1,112 @@
//! Image cache with LRU eviction.
//!
//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction.
use std::fs;
use std::path::PathBuf;
/// Кэш изображений с LRU eviction по mtime
#[allow(dead_code)]
pub struct ImageCache {
cache_dir: PathBuf,
max_size_bytes: u64,
}
#[allow(dead_code)]
impl ImageCache {
/// Создаёт новый кэш с указанным лимитом в МБ
pub fn new(cache_size_mb: u64) -> Self {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("tele-tui")
.join("images");
// Создаём директорию кэша если не существует
let _ = fs::create_dir_all(&cache_dir);
Self {
cache_dir,
max_size_bytes: cache_size_mb * 1024 * 1024,
}
}
/// Проверяет, есть ли файл в кэше
pub fn get_cached(&self, file_id: i32) -> Option<PathBuf> {
let path = self.cache_dir.join(format!("{}.jpg", file_id));
if path.exists() {
// Обновляем mtime для LRU
let _ = filetime::set_file_mtime(&path, filetime::FileTime::now());
Some(path)
} else {
None
}
}
/// Кэширует файл, копируя из source_path
pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result<PathBuf, String> {
let dest = self.cache_dir.join(format!("{}.jpg", file_id));
fs::copy(source_path, &dest).map_err(|e| format!("Ошибка кэширования: {}", e))?;
// Evict если превышен лимит
self.evict_if_needed();
Ok(dest)
}
/// Удаляет старые файлы если кэш превышает лимит
fn evict_if_needed(&self) {
let entries = match fs::read_dir(&self.cache_dir) {
Ok(entries) => entries,
Err(_) => return,
};
let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let meta = e.metadata().ok()?;
let mtime = meta.modified().ok()?;
Some((e.path(), meta.len(), mtime))
})
.collect();
let total_size: u64 = files.iter().map(|(_, size, _)| size).sum();
if total_size <= self.max_size_bytes {
return;
}
// Сортируем по mtime (старые первые)
files.sort_by_key(|(_, _, mtime)| *mtime);
let mut current_size = total_size;
for (path, size, _) in &files {
if current_size <= self.max_size_bytes {
break;
}
let _ = fs::remove_file(path);
current_size -= size;
}
}
}
/// Обёртка для установки mtime без внешней зависимости
#[allow(dead_code)]
mod filetime {
use std::path::Path;
pub struct FileTime;
impl FileTime {
pub fn now() -> Self {
FileTime
}
}
pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> {
// На macOS/Linux можно использовать utime, но для простоты
// достаточно прочитать файл (обновит atime) — LRU по mtime не критичен
// для нашего use case. Файл будет перезаписан при повторном скачивании.
Ok(())
}
}

View File

@@ -0,0 +1,125 @@
//! Terminal image renderer using ratatui-image.
//!
//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images
//! as StatefulProtocol widgets.
//!
//! Implements LRU-like caching for protocols to avoid unlimited memory growth.
use crate::types::MessageId;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use std::collections::HashMap;
/// Максимальное количество кэшированных протоколов (LRU)
const MAX_CACHED_PROTOCOLS: usize = 100;
/// Рендерер изображений для терминала с LRU кэшем
pub struct ImageRenderer {
picker: Picker,
/// Протоколы рендеринга для каждого сообщения (message_id -> protocol)
protocols: HashMap<i64, StatefulProtocol>,
/// Порядок доступа для LRU (message_id -> порядковый номер)
access_order: HashMap<i64, usize>,
/// Счётчик для отслеживания порядка доступа
access_counter: usize,
}
impl ImageRenderer {
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
pub fn new() -> Option<Self> {
let picker = Picker::from_query_stdio().ok()?;
Some(Self {
picker,
protocols: HashMap::new(),
access_order: HashMap::new(),
access_counter: 0,
})
}
/// Создаёт ImageRenderer с принудительным Halfblocks (быстро, для inline preview)
pub fn new_fast() -> Option<Self> {
let mut picker = Picker::from_fontsize((8, 12));
picker.set_protocol_type(ProtocolType::Halfblocks);
Some(Self {
picker,
protocols: HashMap::new(),
access_order: HashMap::new(),
access_counter: 0,
})
}
/// Загружает изображение из файла и создаёт протокол рендеринга.
///
/// Если протокол уже существует, не загружает повторно (кэширование).
/// Использует LRU eviction при превышении лимита.
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
let msg_id_i64 = msg_id.as_i64();
// Оптимизация: если протокол уже есть, обновляем access time и возвращаем
if self.protocols.contains_key(&msg_id_i64) {
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
return Ok(());
}
// Evict старые протоколы если превышен лимит
if self.protocols.len() >= MAX_CACHED_PROTOCOLS {
self.evict_oldest_protocol();
}
let img = image::ImageReader::open(path)
.map_err(|e| format!("Ошибка открытия: {}", e))?
.decode()
.map_err(|e| format!("Ошибка декодирования: {}", e))?;
let protocol = self.picker.new_resize_protocol(img);
self.protocols.insert(msg_id_i64, protocol);
// Обновляем access order
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
Ok(())
}
/// Удаляет самый старый протокол (LRU eviction)
fn evict_oldest_protocol(&mut self) {
if let Some((&oldest_id, _)) = self.access_order.iter().min_by_key(|(_, &order)| order) {
self.protocols.remove(&oldest_id);
self.access_order.remove(&oldest_id);
}
}
/// Получает мутабельную ссылку на протокол для рендеринга.
///
/// Обновляет access time для LRU.
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
let msg_id_i64 = msg_id.as_i64();
if self.protocols.contains_key(&msg_id_i64) {
// Обновляем access time
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
}
self.protocols.get_mut(&msg_id_i64)
}
/// Удаляет протокол для сообщения
#[allow(dead_code)]
pub fn remove(&mut self, msg_id: &MessageId) {
let msg_id_i64 = msg_id.as_i64();
self.protocols.remove(&msg_id_i64);
self.access_order.remove(&msg_id_i64);
}
/// Очищает все протоколы
#[allow(dead_code)]
pub fn clear(&mut self) {
self.protocols.clear();
self.access_order.clear();
self.access_counter = 0;
}
}

View File

@@ -0,0 +1,9 @@
//! Media handling module (feature-gated under "images").
//!
//! Provides image caching and terminal image rendering via ratatui-image.
#[cfg(feature = "images")]
pub mod cache;
#[cfg(feature = "images")]
pub mod image_renderer;

View File

@@ -0,0 +1,362 @@
//! Desktop notifications module
//!
//! Provides cross-platform desktop notifications for new messages.
use crate::tdlib::{ChatInfo, MessageInfo};
use crate::types::ChatId;
use std::collections::HashSet;
#[cfg(feature = "notifications")]
use notify_rust::{Notification, Timeout};
/// Manages desktop notifications
#[allow(dead_code)]
pub struct NotificationManager {
/// Whether notifications are enabled
enabled: bool,
/// Set of muted chat IDs (don't notify for these chats)
muted_chats: HashSet<ChatId>,
/// Only notify for mentions (@username)
only_mentions: bool,
/// Show message preview text
show_preview: bool,
/// Notification timeout in milliseconds (0 = system default)
timeout_ms: i32,
/// Notification urgency level
urgency: String,
}
#[allow(dead_code)]
impl NotificationManager {
/// Creates a new notification manager with default settings
pub fn new() -> Self {
Self {
enabled: false,
muted_chats: HashSet::new(),
only_mentions: false,
show_preview: true,
timeout_ms: 5000,
urgency: "normal".to_string(),
}
}
/// Creates a notification manager with custom settings
pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self {
Self {
enabled,
muted_chats: HashSet::new(),
only_mentions,
show_preview,
timeout_ms: 5000,
urgency: "normal".to_string(),
}
}
/// Creates a notification manager from application config.
pub fn from_config(config: &crate::config::NotificationsConfig) -> Self {
let mut manager = Self::new();
manager.configure(config);
manager
}
/// Applies notification settings from application config.
pub fn configure(&mut self, config: &crate::config::NotificationsConfig) {
self.set_enabled(config.enabled);
self.set_only_mentions(config.only_mentions);
self.set_show_preview(config.show_preview);
self.set_timeout(config.timeout_ms);
self.set_urgency(config.urgency.clone());
}
/// Sets whether notifications are enabled
pub fn set_enabled(&mut self, enabled: bool) {
self.enabled = enabled;
}
/// Sets whether to only notify for mentions
pub fn set_only_mentions(&mut self, only_mentions: bool) {
self.only_mentions = only_mentions;
}
/// Sets notification timeout in milliseconds
pub fn set_timeout(&mut self, timeout_ms: i32) {
self.timeout_ms = timeout_ms;
}
/// Sets whether message preview text should be shown in notifications
pub fn set_show_preview(&mut self, show_preview: bool) {
self.show_preview = show_preview;
}
/// Sets notification urgency level
pub fn set_urgency(&mut self, urgency: String) {
self.urgency = urgency;
}
/// Adds a chat to the muted list
pub fn mute_chat(&mut self, chat_id: ChatId) {
self.muted_chats.insert(chat_id);
}
/// Removes a chat from the muted list
pub fn unmute_chat(&mut self, chat_id: ChatId) {
self.muted_chats.remove(&chat_id);
}
/// Checks if a chat should be muted based on Telegram mute status
pub fn sync_muted_chats(&mut self, chats: &[ChatInfo]) {
self.muted_chats.clear();
for chat in chats {
if chat.is_muted {
self.muted_chats.insert(chat.id);
}
}
}
/// Sends a notification for a new message
///
/// # Arguments
///
/// * `chat` - Chat information
/// * `message` - Message information
/// * `sender_name` - Name of the message sender
///
/// Returns `Ok(())` if notification was sent or skipped, `Err` if failed
pub fn notify_new_message(
&self,
chat: &ChatInfo,
message: &MessageInfo,
sender_name: &str,
) -> Result<(), String> {
// Check if notifications are enabled
if !self.enabled {
tracing::debug!("Notifications disabled, skipping");
return Ok(());
}
// Don't notify for outgoing messages
if message.is_outgoing() {
tracing::debug!("Outgoing message, skipping notification");
return Ok(());
}
// Check if chat is muted
if self.muted_chats.contains(&chat.id) {
tracing::debug!("Chat {} is muted, skipping notification", chat.title);
return Ok(());
}
// Check if we only notify for mentions
if self.only_mentions && !message.has_mention() {
tracing::debug!("only_mentions=true but no mention found, skipping");
return Ok(());
}
// Format the notification
let title = &chat.title;
let body = self.format_message_body(sender_name, message);
tracing::debug!("Sending notification for chat: {}", title);
// Send the notification
self.send_notification(title, &body)?;
Ok(())
}
/// Formats the message body for notification
fn format_message_body(&self, sender_name: &str, message: &MessageInfo) -> String {
// For groups, include sender name. For private chats, sender name is in title
let prefix = if !sender_name.is_empty() && sender_name != message.sender_name() {
format!("{}: ", sender_name)
} else {
String::new()
};
let content = if self.show_preview {
let text = message.text();
// Check if message is empty (media, sticker, etc.)
if text.is_empty() {
"Новое сообщение".to_string()
} else {
// Beautify media labels with emojis
let beautified = Self::beautify_media_labels(text);
// Limit preview length (use char count, not byte count for UTF-8 safety)
const MAX_PREVIEW_CHARS: usize = 147;
let char_count = beautified.chars().count();
if char_count > MAX_PREVIEW_CHARS {
let truncated: String = beautified.chars().take(MAX_PREVIEW_CHARS).collect();
format!("{}...", truncated)
} else {
beautified
}
}
} else {
"Новое сообщение".to_string()
};
format!("{}{}", prefix, content)
}
/// Replaces text media labels with emoji-enhanced versions
fn beautify_media_labels(text: &str) -> String {
text.replace("[Фото]", "📷 Фото")
.replace("[Видео]", "🎥 Видео")
.replace("[GIF]", "🎞️ GIF")
.replace("[Голосовое]", "🎤 Голосовое")
.replace("[Стикер:", "🎨 Стикер:")
.replace("[Файл:", "📎 Файл:")
.replace("[Аудио:", "🎵 Аудио:")
.replace("[Аудио]", "🎵 Аудио")
.replace("[Видеосообщение]", "📹 Видеосообщение")
.replace("[Локация]", "📍 Локация")
.replace("[Контакт:", "👤 Контакт:")
.replace("[Опрос:", "📊 Опрос:")
.replace("[Место встречи:", "📍 Место встречи:")
.replace("[Неподдерживаемый тип сообщения]", "📨 Сообщение")
}
/// Sends a desktop notification
///
/// Returns `Ok(())` if notification was sent successfully or skipped.
/// Logs errors but doesn't fail - notifications are not critical for app functionality.
#[cfg(feature = "notifications")]
fn send_notification(&self, title: &str, body: &str) -> Result<(), String> {
// Don't send if notifications are disabled
if !self.enabled {
return Ok(());
}
// Determine timeout
let timeout = if self.timeout_ms <= 0 {
Timeout::Default
} else {
Timeout::Milliseconds(self.timeout_ms as u32)
};
// Build notification
let mut notification = Notification::new();
notification
.summary(title)
.body(body)
.icon("telegram")
.appname("tele-tui")
.timeout(timeout);
// Set urgency if supported
#[cfg(all(unix, not(target_os = "macos")))]
{
use notify_rust::Urgency;
let urgency_level = match self.urgency.to_lowercase().as_str() {
"low" => Urgency::Low,
"critical" => Urgency::Critical,
_ => Urgency::Normal,
};
notification.urgency(urgency_level);
}
match notification.show() {
Ok(_) => Ok(()),
Err(e) => {
// Log error but don't fail - notifications are optional
tracing::warn!("Failed to send desktop notification: {}", e);
// Return Ok to not break the app flow
Ok(())
}
}
}
/// Fallback when notifications feature is disabled
#[cfg(not(feature = "notifications"))]
fn send_notification(&self, _title: &str, _body: &str) -> Result<(), String> {
Ok(())
}
}
impl Default for NotificationManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_notification_manager_creation() {
let manager = NotificationManager::new();
assert!(!manager.enabled); // disabled by default
assert!(!manager.only_mentions);
assert!(manager.show_preview);
}
#[test]
fn test_mute_unmute() {
let mut manager = NotificationManager::new();
let chat_id = ChatId::new(123);
manager.mute_chat(chat_id);
assert!(manager.muted_chats.contains(&chat_id));
manager.unmute_chat(chat_id);
assert!(!manager.muted_chats.contains(&chat_id));
}
#[test]
fn test_disabled_notifications() {
let mut manager = NotificationManager::new();
manager.set_enabled(false);
// Should return Ok without sending notification
let result = manager.send_notification("Test", "Body");
assert!(result.is_ok());
}
#[test]
fn test_only_mentions_setting() {
let mut manager = NotificationManager::new();
assert!(!manager.only_mentions);
manager.set_only_mentions(true);
assert!(manager.only_mentions);
manager.set_only_mentions(false);
assert!(!manager.only_mentions);
}
#[test]
fn test_beautify_media_labels() {
// Test photo
assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
// Test video
assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
// Test sticker with emoji
assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
// Test audio with title
assert_eq!(
NotificationManager::beautify_media_labels("[Аудио: Artist - Song]"),
"🎵 Аудио: Artist - Song]"
);
// Test file
assert_eq!(
NotificationManager::beautify_media_labels("[Файл: document.pdf]"),
"📎 Файл: document.pdf]"
);
// Test regular text (no changes)
assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
// Test mixed content
assert_eq!(
NotificationManager::beautify_media_labels("[Фото] Check this out!"),
"📷 Фото Check this out!"
);
}
}

View File

@@ -0,0 +1,288 @@
// Test App builder
use super::FakeTdClient;
use crate::app::{App, AppScreen, ChatState, InputMode};
use crate::config::Config;
use crate::tdlib::AuthState;
use crate::tdlib::{ChatInfo, MessageInfo};
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
use std::collections::HashMap;
/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах.
#[allow(dead_code)]
pub struct TestAppBuilder {
config: Config,
screen: AppScreen,
chats: Vec<ChatInfo>,
selected_chat_id: Option<i64>,
message_input: String,
is_searching: bool,
search_query: String,
chat_state: Option<ChatState>,
input_mode: Option<InputMode>,
messages: HashMap<i64, Vec<MessageInfo>>,
status_message: Option<String>,
auth_state: Option<AuthState>,
phone_input: Option<String>,
code_input: Option<String>,
password_input: Option<String>,
}
impl Default for TestAppBuilder {
fn default() -> Self {
Self::new()
}
}
#[allow(dead_code)]
impl TestAppBuilder {
pub fn new() -> Self {
Self {
config: Config::default(),
screen: AppScreen::Main,
chats: vec![],
selected_chat_id: None,
message_input: String::new(),
is_searching: false,
search_query: String::new(),
chat_state: None,
input_mode: None,
messages: HashMap::new(),
status_message: None,
auth_state: None,
phone_input: None,
code_input: None,
password_input: None,
}
}
/// Установить экран
pub fn screen(mut self, screen: AppScreen) -> Self {
self.screen = screen;
self
}
/// Установить конфиг
pub fn config(mut self, config: Config) -> Self {
self.config = config;
self
}
/// Добавить чат
pub fn with_chat(mut self, chat: ChatInfo) -> Self {
self.chats.push(chat);
self
}
/// Добавить несколько чатов
pub fn with_chats(mut self, chats: Vec<ChatInfo>) -> Self {
self.chats.extend(chats);
self
}
/// Выбрать чат
pub fn selected_chat(mut self, chat_id: i64) -> Self {
self.selected_chat_id = Some(chat_id);
self
}
/// Установить текст в инпуте
pub fn message_input(mut self, text: &str) -> Self {
self.message_input = text.to_string();
self
}
/// Режим поиска
pub fn searching(mut self, query: &str) -> Self {
self.is_searching = true;
self.search_query = query.to_string();
self
}
/// Режим редактирования сообщения
pub fn editing_message(mut self, message_id: i64, selected_index: usize) -> Self {
self.chat_state = Some(ChatState::Editing {
message_id: MessageId::new(message_id),
selected_index,
});
self
}
/// Режим ответа на сообщение
pub fn replying_to(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Reply { message_id: MessageId::new(message_id) });
self
}
/// Режим выбора реакции
pub fn reaction_picker(mut self, message_id: i64, available_reactions: Vec<String>) -> Self {
self.chat_state = Some(ChatState::ReactionPicker {
message_id: MessageId::new(message_id),
available_reactions,
selected_index: 0,
});
self
}
/// Режим профиля
pub fn profile_mode(mut self, info: crate::tdlib::ProfileInfo) -> Self {
self.chat_state = Some(ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
});
self
}
/// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.chat_state =
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self
}
/// Добавить сообщение для чата
pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self {
self.messages.entry(chat_id).or_default().push(message);
self
}
/// Добавить несколько сообщений для чата
pub fn with_messages(mut self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages.entry(chat_id).or_default().extend(messages);
self
}
/// Установить выбранное сообщение (режим selection)
pub fn selecting_message(mut self, selected_index: usize) -> Self {
self.chat_state = Some(ChatState::MessageSelection { selected_index });
self
}
/// Режим поиска по сообщениям в чате
pub fn message_search(mut self, query: &str) -> Self {
self.chat_state = Some(ChatState::SearchInChat {
query: query.to_string(),
results: Vec::new(),
selected_index: 0,
});
self
}
/// Установить Insert mode
pub fn insert_mode(mut self) -> Self {
self.input_mode = Some(InputMode::Insert);
self
}
/// Режим пересылки сообщения
pub fn forward_mode(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id) });
self
}
/// Установить статус сообщение (для loading screen)
pub fn status_message(mut self, message: &str) -> Self {
self.status_message = Some(message.to_string());
self
}
/// Установить auth state
pub fn auth_state(mut self, state: AuthState) -> Self {
self.auth_state = Some(state);
self
}
/// Установить phone input
pub fn phone_input(mut self, phone: &str) -> Self {
self.phone_input = Some(phone.to_string());
self
}
/// Установить code input
pub fn code_input(mut self, code: &str) -> Self {
self.code_input = Some(code.to_string());
self
}
/// Установить password input
pub fn password_input(mut self, password: &str) -> Self {
self.password_input = Some(password.to_string());
self
}
/// Построить App с FakeTdClient
///
/// Создаёт App с FakeTdClient, подходит для любых тестов включая
/// интеграционные тесты логики.
pub fn build(self) -> App<FakeTdClient> {
// Создаём FakeTdClient с чатами и сообщениями
let mut fake_client = FakeTdClient::new();
// Добавляем чаты
for chat in &self.chats {
fake_client = fake_client.with_chat(chat.clone());
}
// Добавляем сообщения
for (chat_id, messages) in self.messages {
fake_client = fake_client.with_messages(chat_id, messages);
}
// Устанавливаем текущий чат если нужно
if let Some(chat_id) = self.selected_chat_id {
*fake_client.current_chat_id.lock().unwrap() = Some(chat_id);
}
// Устанавливаем auth state если нужно
if let Some(auth_state) = self.auth_state {
fake_client = fake_client.with_auth_state(auth_state);
}
// Создаём App с FakeTdClient
let mut app = App::with_client(self.config, fake_client);
app.screen = self.screen;
app.chats = self.chats;
app.selected_chat_id = self.selected_chat_id.map(ChatId::new);
app.message_input = self.message_input;
app.is_searching = self.is_searching;
app.search_query = self.search_query;
// Применяем chat_state если он установлен
if let Some(chat_state) = self.chat_state {
app.chat_state = chat_state;
}
// Применяем input_mode если он установлен
if let Some(input_mode) = self.input_mode {
app.input_mode = input_mode;
}
// Применяем status_message
if let Some(status) = self.status_message {
app.status_message = Some(status);
}
// Применяем auth inputs
if let Some(phone) = self.phone_input {
app.set_phone_input(phone);
}
if let Some(code) = self.code_input {
app.set_code_input(code);
}
if let Some(password) = self.password_input {
app.set_password_input(password);
}
// Выбираем первый чат если есть
if !app.chats.is_empty() {
let mut list_state = ListState::default();
list_state.select(Some(0));
app.chat_list_state = list_state;
}
app
}
}

View File

@@ -0,0 +1,6 @@
//! Test-only support for deterministic UI fixtures and integration tests.
pub mod app_builder;
pub mod snapshot_utils;
pub use tele_core::test_support::{fake_tdclient, test_data, FakeTdClient};

View File

@@ -0,0 +1,144 @@
// Snapshot testing utilities
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Modifier};
use ratatui::Terminal;
/// Конвертирует Buffer в читаемую строку для snapshot тестов
pub fn buffer_to_string(buffer: &Buffer) -> String {
let area = buffer.area();
let mut result = String::new();
for y in 0..area.height {
let mut line = String::new();
for x in 0..area.width {
line.push_str(buffer[(x, y)].symbol());
}
// Убираем trailing spaces в конце строки
result.push_str(line.trim_end());
if y < area.height - 1 {
result.push('\n');
}
}
result
}
/// Serializes only cells with non-default style, grouped by row and style.
pub fn buffer_to_style_snapshot(buffer: &Buffer) -> String {
let area = buffer.area();
let mut rows = Vec::new();
for y in 0..area.height {
let mut segments = Vec::new();
let mut x = 0;
while x < area.width {
let cell = &buffer[(x, y)];
if is_default_style(cell) {
x += 1;
continue;
}
let start = x;
let fg = cell.fg;
let bg = cell.bg;
let modifier = cell.modifier;
let mut text = String::new();
while x < area.width {
let next = &buffer[(x, y)];
if is_default_style(next)
|| next.fg != fg
|| next.bg != bg
|| next.modifier != modifier
{
break;
}
text.push_str(next.symbol());
x += 1;
}
segments.push(format!(
"{}..{} {:?}/{:?}/{:?}: {:?}",
start,
x.saturating_sub(1),
fg,
bg,
modifier,
text.trim_end()
));
}
if !segments.is_empty() {
rows.push(format!("y={}: {}", y, segments.join(" | ")));
}
}
rows.join("\n")
}
fn is_default_style(cell: &ratatui::buffer::Cell) -> bool {
cell.fg == Color::Reset && cell.bg == Color::Reset && cell.modifier == Modifier::empty()
}
/// Создаёт TestBackend с заданным размером и рендерит UI
pub fn render_to_buffer<F>(width: u16, height: u16, render_fn: F) -> Buffer
where
F: FnOnce(&mut ratatui::Frame),
{
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(render_fn).unwrap();
terminal.backend().buffer().clone()
}
/// Макрос для упрощения snapshot тестов
#[macro_export]
macro_rules! assert_ui_snapshot {
($name:expr, $width:expr, $height:expr, $render_fn:expr) => {{
use $crate::test_support::snapshot_utils::{buffer_to_string, render_to_buffer};
let buffer = render_to_buffer($width, $height, $render_fn);
let output = buffer_to_string(&buffer);
insta::assert_snapshot!($name, output);
}};
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders};
#[test]
fn test_buffer_to_string_simple() {
let buffer = render_to_buffer(10, 3, |f| {
let block = Block::default().borders(Borders::ALL).title("Hi");
f.render_widget(block, f.area());
});
let result = buffer_to_string(&buffer);
assert!(result.contains("Hi"));
assert!(result.contains(""));
assert!(result.contains(""));
}
#[test]
fn test_buffer_to_string_removes_trailing_spaces() {
let buffer = render_to_buffer(20, 3, |f| {
let block = Block::default().title("Test");
f.render_widget(block, Rect::new(0, 0, 10, 3));
});
let result = buffer_to_string(&buffer);
let lines: Vec<&str> = result.lines().collect();
// Проверяем что trailing spaces убраны
for line in lines {
assert!(!line.ends_with(' ') || line.trim().is_empty());
}
}
}

View File

@@ -0,0 +1,137 @@
use crate::app::App;
use crate::tdlib::AuthState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(30),
Constraint::Length(15),
Constraint::Percentage(30),
])
.split(area);
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(vertical_chunks[1]);
let auth_area = horizontal_chunks[1];
let auth_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Title
Constraint::Length(4), // Instructions
Constraint::Length(3), // Input
Constraint::Length(2), // Error/Status message
Constraint::Min(0), // Spacer
])
.split(auth_area);
// Title
let title = Paragraph::new("TTUI - Telegram Authentication")
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(title, auth_chunks[0]);
// Instructions and Input based on auth state
match &app.td_client.auth_state() {
AuthState::WaitPhoneNumber => {
let instructions = vec![
Line::from("Введите номер телефона в международном формате"),
Line::from("Пример: +79991111111"),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let input_text = format!("📱 {}", app.phone_input());
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Phone Number "),
);
f.render_widget(input, auth_chunks[2]);
}
AuthState::WaitCode => {
let instructions = vec![
Line::from("Введите код подтверждения из Telegram"),
Line::from("Код был отправлен на ваш номер"),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let input_text = format!("🔐 {}", app.code_input());
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Verification Code "),
);
f.render_widget(input, auth_chunks[2]);
}
AuthState::WaitPassword => {
let instructions = vec![
Line::from("Введите пароль двухфакторной аутентификации"),
Line::from(""),
];
let instructions_widget = Paragraph::new(instructions)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
f.render_widget(instructions_widget, auth_chunks[1]);
let masked_password = "*".repeat(app.password_input().len());
let input_text = format!("🔒 {}", masked_password);
let input = Paragraph::new(input_text)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Password "));
f.render_widget(input, auth_chunks[2]);
}
_ => {}
}
// Error or status message
if let Some(error) = &app.error_message {
let error_widget = Paragraph::new(error.as_str())
.style(Style::default().fg(Color::Red))
.alignment(Alignment::Center);
f.render_widget(error_widget, auth_chunks[3]);
} else if let Some(status) = &app.status_message {
let status_widget = Paragraph::new(status.as_str())
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center);
f.render_widget(status_widget, auth_chunks[3]);
}
}

View File

@@ -0,0 +1,107 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search box
Constraint::Min(0), // Chat list
Constraint::Length(3), // User status
])
.split(area);
// Search box
let search_text = if app.is_searching {
if app.search_query.is_empty() {
"🔍 Введите для поиска...".to_string()
} else {
format!("🔍 {}", app.search_query)
}
} else {
"🔍 Ctrl+S для поиска".to_string()
};
let search_style = if app.is_searching {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Rgb(160, 160, 160))
};
let search = Paragraph::new(search_text)
.block(Block::default().borders(Borders::ALL))
.style(search_style);
f.render_widget(search, chat_chunks[0]);
// Chat list (filtered if searching)
let filtered_chats = app.get_filtered_chats();
let items: Vec<ListItem> = filtered_chats
.iter()
.map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id);
let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
components::render_chat_list_item(chat, is_selected, user_status)
})
.collect();
// Заголовок блока: обычный или режим пересылки
let block = if app.is_forwarding() {
Block::default()
.title(" ↪ Выберите чат ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
} else {
Block::default().borders(Borders::ALL)
};
let chats_list = List::new(items).block(block).highlight_style(
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(Color::Yellow),
);
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status - показываем статус выбранного или выделенного чата
let status_chat_id = if app.selected_chat_id.is_some() {
app.selected_chat_id
} else {
let filtered = app.get_filtered_chats();
app.chat_list_state
.selected()
.and_then(|i| filtered.get(i).map(|c| c.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)),
None => ("".to_string(), Color::DarkGray),
};
let status = Paragraph::new(status_text)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(status_color));
f.render_widget(status, chat_chunks[2]);
}
/// Форматирует статус пользователя для отображения в статус-баре
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
match status {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
(crate::utils::format_was_online(*was_online), Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
}

View File

@@ -0,0 +1,78 @@
use crate::tdlib::{ChatInfo, UserOnlineStatus};
use ratatui::{
style::{Color, Style},
widgets::ListItem,
};
/// Рендерит элемент списка чатов
///
/// # Параметры
/// - `chat`: Информация о чате
/// - `is_selected`: Выбран ли этот чат
/// - `user_status`: Онлайн-статус пользователя (если доступен)
///
/// # Возвращает
/// ListItem с форматированным отображением чата
pub fn render_chat_list_item(
chat: &ChatInfo,
is_selected: bool,
user_status: Option<&UserOnlineStatus>,
) -> ListItem<'static> {
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
// Онлайн-статус (зелёная точка для онлайн)
let status_icon = match user_status {
Some(UserOnlineStatus::Online) => "",
_ => " ",
};
let prefix = if is_selected { "" } else { " " };
let username_text = chat
.username
.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
// Индикатор упоминаний @
let mention_badge = if chat.unread_mention_count > 0 {
" @".to_string()
} else {
String::new()
};
// Индикатор черновика ✎
let draft_badge = if chat.draft_text.is_some() {
"".to_string()
} else {
String::new()
};
let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count)
} else {
String::new()
};
let content = format!(
"{}{}{}{}{}{}{}{}{}",
prefix,
status_icon,
pin_icon,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_badge,
unread_badge
);
// Цвет: онлайн — зелёные, остальные — белые
let style = match user_status {
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
ListItem::new(content).style(style)
}

View File

@@ -0,0 +1,104 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит модалку выбора реакций (emoji picker)
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `available_reactions`: Список доступных эмодзи
/// - `selected_index`: Индекс выбранного эмодзи
pub fn render_emoji_picker(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = available_reactions.len().div_ceil(emojis_per_row);
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Формируем содержимое - сетка эмодзи
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}

View File

@@ -0,0 +1,50 @@
use ratatui::{
style::{Color, Style},
text::{Line, Span},
};
/// Рендерит текст с курсором в виде Line
///
/// # Параметры
/// - `prefix`: Префикс перед текстом (например, "Сообщение: ")
/// - `text`: Текст в поле ввода
/// - `cursor_pos`: Позиция курсора (индекс символа)
/// - `color`: Цвет текста и курсора
///
/// # Возвращает
/// Line с текстом и блочным курсором на указанной позиции
pub fn render_input_field(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
// Ограничиваем cursor_pos границами текста
let safe_cursor_pos = cursor_pos.min(chars.len());
// Текст до курсора
if safe_cursor_pos > 0 {
let before: String = chars[..safe_cursor_pos].iter().collect();
spans.push(Span::styled(before, Style::default().fg(color)));
}
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));
}
// Текст после курсора
if safe_cursor_pos + 1 < chars.len() {
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
spans.push(Span::styled(after, Style::default().fg(color)));
}
Line::from(spans)
}

View File

@@ -0,0 +1,763 @@
//! Message bubble component
//!
//! Отвечает за рендеринг отдельных элементов списка сообщений:
//! - Разделители дат
//! - Заголовки отправителей
//! - Сами сообщения (с forward, reply, reactions)
use crate::config::Config;
use crate::formatting;
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
/// Начальная позиция в символах от начала оригинального текста
start_offset: usize,
}
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
let mut all_lines = Vec::new();
let mut char_offset = 0;
for segment in text.split('\n') {
let wrapped = wrap_paragraph(segment, max_width, char_offset);
all_lines.extend(wrapped);
char_offset += segment.chars().count() + 1; // +1 за '\n'
}
if all_lines.is_empty() {
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
}
all_lines
}
/// Разбивает один абзац (без `\n`) на строки по ширине
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_start_offset = base_offset;
let chars: Vec<char> = text.chars().collect();
let mut word_start = 0;
let mut in_word = false;
for (i, ch) in chars.iter().enumerate() {
if ch.is_whitespace() {
if in_word {
let word: String = chars[word_start..i].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
current_width = word_width;
line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
current_width = word_width;
line_start_offset = base_offset + word_start;
}
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
if in_word {
let word: String = chars[word_start..].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
line_start_offset = base_offset + word_start;
}
}
if !current_line.is_empty() {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
}
if result.is_empty() {
result.push(WrappedLine { text: String::new(), start_offset: base_offset });
}
result
}
/// Рендерит разделитель даты
///
/// # Аргументы
///
/// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
pub fn render_date_separator(
date: i32,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
lines.push(Line::from("")); // Пустая строка перед разделителем
}
let date_str = format_date(date);
let date_line = format!("──────── {} ────────", date_str);
let padding = content_width.saturating_sub(date_line.chars().count()) / 2;
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(date_line, Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(""));
lines
}
/// Рендерит заголовок отправителя
///
/// # Аргументы
///
/// * `is_outgoing` - исходящее ли сообщение
/// * `sender_name` - имя отправителя
/// * `content_width` - ширина области для выравнивания
/// * `is_first` - первый ли это заголовок в группе (если нет, добавляется пустая строка сверху)
pub fn render_sender_header(
is_outgoing: bool,
sender_name: &str,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
lines.push(Line::from("")); // Пустая строка между группами
}
let sender_style = if is_outgoing {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
if is_outgoing {
// Заголовок "Вы" справа
let header_text = format!("{} ────────────────", sender_name);
let header_len = header_text.chars().count();
let padding = content_width.saturating_sub(header_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
} else {
// Заголовок входящих слева
lines.push(Line::from(vec![
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Рендерит bubble одного сообщения
///
/// # Аргументы
///
/// * `msg` - сообщение для рендеринга
/// * `config` - конфигурация (цвета)
/// * `content_width` - ширина области для рендеринга
/// * `selected_msg_id` - ID выбранного сообщения (для подсветки)
pub fn render_message_bubble(
msg: &MessageInfo,
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
playback_state: Option<&PlaybackState>,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let is_selected = selected_msg_id == Some(msg.id());
// Маркер выбора (всегда резервируем место для ▶, чтобы текст не сдвигался)
let selection_marker = if is_selected { "" } else { " " };
let marker_len = 2;
// Цвет сообщения
let msg_color = if is_selected {
config.parse_color(&config.colors.selected_message)
} else if msg.is_outgoing() {
config.parse_color(&config.colors.outgoing_message)
} else {
config.parse_color(&config.colors.incoming_message)
};
// Отображаем forward если есть
if let Some(forward) = msg.forward_from() {
let forward_line = format!("↪ Переслано от {}", forward.sender_name);
let forward_len = forward_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(forward_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
forward_line,
Style::default().fg(Color::Magenta),
)]));
}
}
// Отображаем reply если есть
if let Some(reply) = msg.reply_to() {
let reply_text: String = reply.text.chars().take(40).collect();
let ellipsis = if reply.text.chars().count() > 40 {
"..."
} else {
""
};
let reply_line = format!("{}: {}{}", reply.sender_name, reply_text, ellipsis);
let reply_len = reply_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(reply_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines
.push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
}
}
// Форматируем время
let time = format_timestamp(msg.date());
if msg.is_outgoing() {
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
let read_mark = if msg.is_read() { "✓✓" } else { "" };
let edit_mark = if msg.is_edited() { "" } else { "" };
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
let time_mark_len = time_mark.chars().count() + 1;
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
let total_wrapped = wrapped_lines.len();
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count();
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 {
// Первая (или единственная) строка — маркер
line_spans.push(Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
// Остальные строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
line_spans.extend(formatted_spans);
line_spans.push(Span::styled(
format!(" {}", time_mark),
Style::default().fg(Color::Gray),
));
lines.push(Line::from(line_spans));
} else {
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 {
line_spans.push(Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
// Средние строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
} else {
// Входящие: слева, формат "(HH:MM ✎) текст"
let edit_mark = if msg.is_edited() { "" } else { "" };
let time_str = format!("({}{})", time, edit_mark);
let time_prefix_len = time_str.chars().count() + 2;
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 {
let mut line_spans = vec![];
line_spans.push(Span::styled(
selection_marker,
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::raw(" "));
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
} else {
let indent = " ".repeat(time_prefix_len + marker_len);
let mut line_spans = vec![Span::raw(indent)];
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
}
// Отображаем реакции под сообщением
if !msg.reactions().is_empty() {
let mut reaction_spans = vec![];
for reaction in msg.reactions() {
if !reaction_spans.is_empty() {
reaction_spans.push(Span::raw(" "));
}
let reaction_text = if reaction.is_chosen {
if reaction.count > 1 {
format!("[{}] {}", reaction.emoji, reaction.count)
} else {
format!("[{}]", reaction.emoji)
}
} else if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
};
let style = if reaction.is_chosen {
Style::default().fg(config.parse_color(&config.colors.reaction_chosen))
} else {
Style::default().fg(config.parse_color(&config.colors.reaction_other))
};
reaction_spans.push(Span::styled(reaction_text, style));
}
if msg.is_outgoing() {
let reactions_text: String = reaction_spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join(" ");
let reactions_len = reactions_text.chars().count();
let padding = content_width.saturating_sub(reactions_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
line_spans.extend(reaction_spans);
lines.push(Line::from(line_spans));
} else {
lines.push(Line::from(reaction_spans));
}
}
// Отображаем индикатор воспроизведения голосового
if msg.has_voice() {
if let Some(voice) = msg.voice_info() {
let status_line =
if let Some(ps) = playback_state.filter(|ps| ps.message_id == msg.id()) {
let icon = match ps.status {
PlaybackStatus::Playing => "",
PlaybackStatus::Paused => "",
PlaybackStatus::Loading => "",
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration)
} else {
let waveform = render_waveform(&voice.waveform, 20);
format!(" {} {:.0}s", waveform, voice.duration)
};
let status_len = status_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
}
}
}
// Отображаем статус фото (если есть)
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloading => {
let status = "📷 ⏳ Загрузка...";
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Yellow)),
]));
} else {
lines
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
}
}
PhotoDownloadState::Error(e) => {
let status = format!("📷 [Ошибка: {}]", e);
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Red)),
]));
} else {
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
}
}
PhotoDownloadState::Downloaded(_) => {
// Всегда показываем inline превью для загруженных фото
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
for _ in 0..img_height {
lines.push(Line::from(""));
}
}
PhotoDownloadState::NotDownloaded => {
// Для незагруженных фото ничего не рендерим,
// текст сообщения уже содержит 📷 prefix
}
}
}
lines
}
/// Информация для отложенного рендеринга изображения поверх placeholder
#[cfg(feature = "images")]
pub struct DeferredImageRender {
pub message_id: MessageId,
/// Путь к файлу изображения
pub photo_path: String,
/// Смещение в строках от начала всего списка сообщений
pub line_offset: usize,
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
pub x_offset: u16,
pub width: u16,
pub height: u16,
}
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
///
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
#[cfg(feature = "images")]
pub fn render_album_bubble(
messages: &[&MessageInfo],
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
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 deferred: Vec<DeferredImageRender> = Vec::new();
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing());
// Selection marker (всегда резервируем место)
let selection_marker = if is_selected { "" } else { " " };
// Фильтруем фото
let photos: Vec<&MessageInfo> = messages.iter().copied().filter(|m| m.has_photo()).collect();
let photo_count = photos.len();
if photo_count == 0 {
// Нет фото — рендерим как обычные сообщения
for msg in messages {
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
}
return (lines, deferred);
}
// Grid layout
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
let rows = photo_count.div_ceil(cols);
// Добавляем маркер выбора на первую строку (всегда — для постоянного отступа)
lines.push(Line::from(vec![Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]));
let grid_start_line = lines.len();
// Генерируем placeholder-строки для сетки
for row in 0..rows {
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
let mut spans = Vec::new();
// Для исходящих — добавляем отступ справа
if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width.saturating_sub(grid_width as usize + 1);
spans.push(Span::raw(" ".repeat(padding)));
}
// Для каждого столбца в этом ряду
for col in 0..cols {
let photo_idx = row * cols + col;
if photo_idx >= photo_count {
break;
}
let msg = photos[photo_idx];
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloaded(path) => {
if line_in_row == 0 {
// Регистрируем deferred render для этого фото
let x_off = if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width
.saturating_sub(grid_width as usize + 1)
as u16;
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
} else {
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
};
deferred.push(DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: grid_start_line
+ row * ALBUM_PHOTO_HEIGHT as usize,
x_offset: x_off,
width: ALBUM_PHOTO_WIDTH,
height: ALBUM_PHOTO_HEIGHT,
});
}
// Пустая строка — placeholder для изображения
}
PhotoDownloadState::Downloading => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled(
"⏳ Загрузка...",
Style::default().fg(Color::Yellow),
));
}
}
PhotoDownloadState::Error(e) => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
let err_text: String = e.chars().take(14).collect();
spans.push(Span::styled(
format!("{}", err_text),
Style::default().fg(Color::Red),
));
}
}
PhotoDownloadState::NotDownloaded => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
}
}
}
}
}
lines.push(Line::from(spans));
}
}
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
let captions: Vec<&str> = messages
.iter()
.map(|m| m.text())
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
.collect();
let msg_color = if is_selected {
config.parse_color(&config.colors.selected_message)
} else if is_outgoing {
config.parse_color(&config.colors.outgoing_message)
} else {
config.parse_color(&config.colors.incoming_message)
};
// Timestamp из последнего сообщения
let Some(last_msg) = messages.last() else {
return (lines, deferred);
};
let time = format_timestamp(last_msg.date());
if !captions.is_empty() {
let caption_text = captions.join(" ");
let time_suffix = format!(" ({})", time);
if is_outgoing {
let total_len = caption_text.chars().count() + time_suffix.chars().count();
let padding = content_width.saturating_sub(total_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(caption_text, Style::default().fg(msg_color)),
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled(caption_text, Style::default().fg(msg_color)),
]));
}
} else {
// Без подписи — только timestamp
let time_text = format!("({})", time);
if is_outgoing {
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(time_text, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
format!(" {}", time_text),
Style::default().fg(Color::Gray),
)]));
}
}
(lines, deferred)
}
/// Вычисляет высоту изображения (в строках) с учётом пропорций
#[cfg(feature = "images")]
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
let aspect = img_height as f64 / img_width as f64;
// Терминальные символы ~2:1 по высоте, компенсируем
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
}
/// Рендерит progress bar для воспроизведения
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
if duration <= 0.0 {
return "".repeat(width);
}
let ratio = (position / duration).clamp(0.0, 1.0);
let filled = (ratio * width as f32) as usize;
let empty = width.saturating_sub(filled + 1);
format!("{}{}", "".repeat(filled), "".repeat(empty))
}
/// Рендерит waveform из base64-encoded данных TDLib
fn render_waveform(waveform_b64: &str, width: usize) -> String {
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if waveform_b64.is_empty() {
return "".repeat(width);
}
// Декодируем waveform (каждый байт = амплитуда 0-255)
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(waveform_b64)
.unwrap_or_default();
if bytes.is_empty() {
return "".repeat(width);
}
// Сэмплируем до нужной ширины
let mut result = String::with_capacity(width * 4);
for i in 0..width {
let byte_idx = i * bytes.len() / width;
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
result.push(BARS[bar_idx]);
}
result
}

View File

@@ -0,0 +1,117 @@
//! Shared message list rendering for search and pinned modals
use crate::tdlib::MessageInfo;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Renders a single message item with marker, sender, date, and wrapped text
pub fn render_message_item(
msg: &MessageInfo,
is_selected: bool,
content_width: usize,
max_preview_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Marker, sender name, and date
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Wrapped message text
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > max_preview_lines {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Calculates scroll offset to keep selected item visible
pub fn calculate_scroll_offset(
selected_index: usize,
lines_per_item: usize,
visible_height: u16,
) -> u16 {
let visible = visible_height.saturating_sub(2) as usize;
let selected_line = selected_index * lines_per_item;
if selected_line > visible / 2 {
(selected_line - visible / 2) as u16
} else {
0
}
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(
shortcuts: &[(&str, &str, Color)],
border_color: Color,
) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".to_string()));
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}
Paragraph::new(Line::from(spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center)
}

View File

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

View File

@@ -0,0 +1,83 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Рендерит центрированную модалку с заданным содержимым
///
/// # Параметры
/// - `f`: Frame для рендеринга
/// - `area`: Область экрана
/// - `title`: Заголовок модалки
/// - `content`: Содержимое модалки (строки текста)
/// - `width`: Ширина модалки
/// - `height`: Высота модалки
/// - `border_color`: Цвет рамки
pub fn render_modal(
f: &mut Frame,
area: Rect,
title: &str,
content: Vec<Line>,
width: u16,
height: u16,
border_color: Color,
) {
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width.min(area.width), height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Рендерим модалку
let modal = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(format!(" {} ", title))
.title_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Рендерит модалку подтверждения удаления
pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
use ratatui::text::Span;
let content = vec![
Line::from(""),
Line::from(Span::styled(
"Удалить сообщение?",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(
" [y/Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Да"),
Span::raw(" "),
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Нет"),
]),
];
render_modal(f, area, "Подтверждение", content, 40, 7, Color::Red);
}

View File

@@ -0,0 +1,194 @@
//! Compose bar / input box rendering
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders input field with cursor at the specified position
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else if app.input_mode == InputMode::Normal {
// Normal mode — dim, no cursor
if app.message_input.is_empty() {
let line = Line::from(vec![Span::styled(
"> Press i to type...",
Style::default().fg(Color::DarkGray),
)]);
(line, "")
} else {
let draft_preview: String = app.message_input.chars().take(60).collect();
let ellipsis = if app.message_input.chars().count() > 60 {
"..."
} else {
""
};
let line = Line::from(Span::styled(
format!("> {}{}", draft_preview, ellipsis),
Style::default().fg(Color::DarkGray),
));
(line, "")
}
} else {
// Insert mode — active, with cursor
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
let border_style = if app.input_mode == InputMode::Insert {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}

View File

@@ -0,0 +1,60 @@
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::Paragraph,
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Индикатор состояния сети
let network_indicator = match app.td_client.network_state() {
NetworkState::Ready => "",
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
NetworkState::Connecting => "⏳ Подключение... | ",
NetworkState::Updating => "⏳ Обновление... | ",
};
let account_indicator = format!("[{}] ", app.current_account_name);
let status = if let Some(msg) = &app.status_message {
format!(" {}{}{} ", account_indicator, network_indicator, msg)
} else if let Some(err) = &app.error_message {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching {
format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
} else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
};
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
} else {
format!(
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
account_indicator, network_indicator
)
};
let style = if matches!(app.td_client.network_state(), NetworkState::WaitingForNetwork) {
Style::default().fg(Color::Red)
} else if !matches!(app.td_client.network_state(), NetworkState::Ready) {
Style::default().fg(Color::Cyan)
} else if app.error_message.is_some() {
Style::default().fg(Color::Red)
} else if app.status_message.is_some() {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Rgb(160, 160, 160))
};
let footer = Paragraph::new(status).style(style);
f.render_widget(footer, area);
}

View File

@@ -0,0 +1,34 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(40),
Constraint::Length(5),
Constraint::Percentage(40),
])
.split(area);
let message = app.status_message.as_deref().unwrap_or("Загрузка...");
let loading = Paragraph::new(message)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" TTUI "));
f.render_widget(loading, chunks[1]);
}

View File

@@ -0,0 +1,89 @@
use super::{chat_list, footer, messages};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
let is_compact = area.width < COMPACT_WIDTH;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Folders/tabs
Constraint::Min(0), // Main content
Constraint::Length(1), // Commands footer
])
.split(area);
render_folders(f, chunks[0], app);
if is_compact {
// Компактный режим: показываем либо список чатов, либо открытый чат
if app.selected_chat_id.is_some() {
// Чат открыт — показываем только сообщения
messages::render(f, chunks[1], app);
} else {
// Чат не открыт — показываем только список чатов
chat_list::render(f, chunks[1], app);
}
} else {
// Обычный режим: две панели
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Chat list
Constraint::Percentage(70), // Messages area
])
.split(chunks[1]);
chat_list::render(f, main_chunks[0], app);
messages::render(f, main_chunks[1], app);
}
footer::render(f, chunks[2], app);
}
fn render_folders<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let mut spans = vec![];
// "All" всегда первая (клавиша 1)
let all_style = if app.selected_folder_id.is_none() {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(" 1:All ", all_style));
// Папки из TDLib (клавиши 2, 3, 4...)
for (i, folder) in app.td_client.folders().iter().enumerate() {
spans.push(Span::raw(""));
let style = if app.selected_folder_id == Some(folder.id) {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
spans.push(Span::styled(format!(" {}:{} ", i + 2, folder.name), style));
}
let folders_line = Line::from(spans);
let folders_widget =
Paragraph::new(folders_line).block(Block::default().title(" TTUI ").borders(Borders::ALL));
f.render_widget(folders_widget, area);
}

View File

@@ -0,0 +1,102 @@
//! Chat message area rendering.
mod header;
mod list;
mod pinned;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::{compose_bar, modals};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use header::render_chat_header;
use list::render_message_list;
use pinned::render_pinned_bar;
pub(crate) use list::wrap_text_with_offsets;
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
#[cfg(feature = "images")]
if let Some(modal_state) = app.image_modal.clone() {
modals::render_image_viewer(f, app, &modal_state);
return;
}
if app.is_profile_mode() {
if let Some(profile) = app.get_profile_info() {
crate::ui::profile::render(f, area, app, profile);
}
return;
}
if app.is_message_search_mode() {
modals::render_search(f, area, app);
return;
}
if app.is_pinned_mode() {
modals::render_pinned(f, area, app);
return;
}
if let Some(chat) = app.get_selected_chat().cloned() {
let input_width = area.width.saturating_sub(4) as usize;
let input_lines: u16 = if input_width > 0 {
let len = app.message_input.chars().count() + 2;
((len as f32 / input_width as f32).ceil() as u16).max(1)
} else {
1
};
let input_height = (input_lines + 2).clamp(3, 10);
let has_pinned = app.td_client.current_pinned_message().is_some();
let message_chunks = if has_pinned {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(input_height),
])
.split(area)
} else {
Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(0),
Constraint::Min(0),
Constraint::Length(input_height),
])
.split(area)
};
render_chat_header(f, message_chunks[0], app, &chat);
render_pinned_bar(f, message_chunks[1], app);
render_message_list(f, message_chunks[2], app);
compose_bar::render(f, message_chunks[3], app);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(empty, area);
}
if app.is_confirm_delete_shown() {
modals::render_delete_confirm(f, area);
}
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&app.chat_state
{
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}

View File

@@ -0,0 +1,55 @@
use crate::app::App;
use crate::tdlib::{ChatInfo, TdClientTrait};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub(super) fn render_chat_header<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &App<T>,
chat: &ChatInfo,
) {
let typing_action = app
.td_client
.typing_status()
.as_ref()
.map(|(_, action, _)| action.clone());
let header_line = if let Some(action) = typing_action {
let mut spans = vec![Span::styled(
format!("👤 {}", chat.title),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
if let Some(username) = &chat.username {
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
}
spans.push(Span::styled(
format!(" {}", action),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC),
));
Line::from(spans)
} else {
let header_text = match &chat.username {
Some(username) => format!("👤 {} {}", chat.title, username),
None => format!("👤 {}", chat.title),
};
Line::from(Span::styled(
header_text,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
};
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
f.render_widget(header, area);
}

View File

@@ -0,0 +1,286 @@
use crate::app::methods::messages::MessageMethods;
use crate::app::App;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Информация о строке после переноса: текст и позиция в оригинале.
pub(crate) struct WrappedLine {
pub text: String,
}
/// Разбивает текст на строки с учётом максимальной ширины.
pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine { text: text.to_string() }];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let chars: Vec<char> = text.chars().collect();
let mut word_start = 0;
let mut in_word = false;
for (i, ch) in chars.iter().enumerate() {
if ch.is_whitespace() {
if in_word {
let word: String = chars[word_start..i].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
current_width = word_width;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine { text: current_line });
current_line = word;
current_width = word_width;
}
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
if in_word {
let word: String = chars[word_start..].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine { text: current_line });
current_line = word;
}
}
if !current_line.is_empty() {
result.push(WrappedLine { text: current_line });
}
if result.is_empty() {
result.push(WrappedLine { text: String::new() });
}
result
}
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом.
pub(super) fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let content_width = area.width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
let selected_msg_id = app.get_selected_message().map(|m| m.id());
let mut selected_msg_line: Option<usize> = None;
#[cfg(feature = "images")]
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
let current_messages = app.td_client.current_chat_messages();
let grouped = group_messages(&current_messages);
let mut is_first_date = true;
let mut is_first_sender = true;
for group in grouped {
match group {
MessageGroup::DateSeparator(date) => {
lines.extend(components::render_date_separator(date, content_width, is_first_date));
is_first_date = false;
is_first_sender = true;
}
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
lines.extend(components::render_sender_header(
is_outgoing,
&sender_name,
content_width,
is_first_sender,
));
is_first_sender = false;
}
MessageGroup::Message(msg) => {
let is_selected = selected_msg_id == Some(msg.id());
if is_selected {
selected_msg_line = Some(lines.len());
}
let bubble_lines = components::render_message_bubble(
msg,
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
);
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
&photo.download_state
{
let inline_width =
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = components::calculate_image_height(
photo.width,
photo.height,
inline_width,
);
let img_width = inline_width as u16;
let bubble_len = bubble_lines.len();
let placeholder_start = lines.len() + bubble_len - img_height as usize;
deferred_images.push(components::DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: placeholder_start,
x_offset: 0,
width: img_width,
height: img_height,
});
}
}
lines.extend(bubble_lines);
}
MessageGroup::Album(album_messages) => {
#[cfg(feature = "images")]
{
let is_selected = album_messages
.iter()
.any(|m| selected_msg_id == Some(m.id()));
if is_selected {
selected_msg_line = Some(lines.len());
}
let (bubble_lines, album_deferred) = components::render_album_bubble(
&album_messages,
app.config(),
content_width,
selected_msg_id,
);
for mut d in album_deferred {
d.line_offset += lines.len();
deferred_images.push(d);
}
lines.extend(bubble_lines);
}
#[cfg(not(feature = "images"))]
{
for msg in &album_messages {
let is_selected = selected_msg_id == Some(msg.id());
if is_selected {
selected_msg_line = Some(lines.len());
}
lines.extend(components::render_message_bubble(
msg,
app.config(),
content_width,
selected_msg_id,
app.playback_state.as_ref(),
));
}
}
}
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
}
let visible_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let base_scroll = total_lines.saturating_sub(visible_height);
let scroll_offset = if app.is_selecting_message() {
if let Some(selected_line) = selected_msg_line {
if selected_line < visible_height / 2 {
0
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
base_scroll
} else {
selected_line.saturating_sub(visible_height / 2)
}
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
}
} else {
base_scroll.saturating_sub(app.message_scroll_offset)
} as u16;
let messages_widget = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL))
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, area);
#[cfg(feature = "images")]
render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset);
}
#[cfg(feature = "images")]
fn render_deferred_images<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &mut App<T>,
deferred_images: &[components::DeferredImageRender],
visible_height: usize,
scroll_offset: u16,
) {
use ratatui_image::StatefulImage;
let should_render_images = app
.last_image_render_time
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true);
if deferred_images.is_empty() || !should_render_images {
return;
}
let content_x = area.x + 1;
let content_y = area.y + 1;
for d in deferred_images {
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
if y_in_content < 0 || y_in_content as usize >= visible_height {
continue;
}
let img_y = content_y + y_in_content as u16;
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
if d.height > remaining_height {
continue;
}
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
if let Some(renderer) = &mut app.inline_image_renderer {
let _ = renderer.load_image(d.message_id, &d.photo_path);
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
}
}
}
app.last_image_render_time = Some(std::time::Instant::now());
}

View File

@@ -0,0 +1,38 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::Paragraph,
Frame,
};
pub(super) fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let Some(pinned_msg) = app.td_client.current_pinned_message() else {
return;
};
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
let ellipsis = if pinned_msg.text().chars().count() > 40 {
"..."
} else {
""
};
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date());
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
let pinned_hint = "Ctrl+P";
let pinned_bar_width = area.width as usize;
let text_len = pinned_text.chars().count();
let hint_len = pinned_hint.chars().count();
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
let pinned_line = Line::from(vec![
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
Span::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]);
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, area);
}

View File

@@ -0,0 +1,59 @@
//! UI rendering module.
//!
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
mod auth;
pub mod chat_list;
pub mod components;
mod compose_bar;
pub mod footer;
mod loading;
mod main_screen;
pub mod messages;
mod modals;
pub mod profile;
use crate::app::{App, AppScreen};
use crate::tdlib::TdClientTrait;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
/// Минимальная высота терминала
const MIN_HEIGHT: u16 = 10;
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 40;
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
// Проверяем минимальный размер терминала
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
render_size_warning(f, area.width, area.height);
return;
}
match app.screen {
AppScreen::Loading => loading::render(f, app),
AppScreen::Auth => auth::render(f, app),
AppScreen::Main => main_screen::render(f, app),
}
// Global overlay: account switcher (renders on top of ANY screen)
if app.account_switcher.is_some() {
modals::render_account_switcher(f, area, app);
}
}
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
let message = format!("{}x{}\nМинимум: {}x{}", width, height, MIN_WIDTH, MIN_HEIGHT);
let warning = Paragraph::new(message)
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center);
f.render_widget(warning, f.area());
}

View File

@@ -0,0 +1,190 @@
//! Account switcher modal
//!
//! Renders a centered popup with account list (SelectAccount) or
//! new account name input (AddAccount).
use crate::app::{AccountSwitcherState, App};
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
/// Renders the account switcher modal overlay.
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let Some(state) = &app.account_switcher else {
return;
};
match state {
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
render_select_account(f, area, accounts, *selected_index, current_account);
}
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
}
}
}
fn render_select_account(
f: &mut Frame,
area: Rect,
accounts: &[crate::accounts::AccountProfile],
selected_index: usize,
current_account: &str,
) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for (idx, account) in accounts.iter().enumerate() {
let is_selected = idx == selected_index;
let is_current = account.name == current_account;
let marker = if is_current { "" } else { " " };
let suffix = if is_current { " (текущий)" } else { "" };
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
}
// Separator
lines.push(Line::from(Span::styled(
" ──────────────────────",
Style::default().fg(Color::DarkGray),
)));
// Add account item
let add_selected = selected_index == accounts.len();
let add_style = if add_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
lines.push(Line::from(""));
// Help bar
lines.push(Line::from(vec![
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::styled("Select", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" a ", Style::default().fg(Color::Cyan)),
Span::styled("Add", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red)),
Span::styled("Close", Style::default().fg(Color::DarkGray)),
]));
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
let content_height = (accounts.len() as u16) + 7;
let height = content_height.min(area.height.saturating_sub(4));
let width = 40u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width, height);
f.render_widget(Clear, modal_area);
let modal = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" АККАУНТЫ ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(modal, modal_area);
}
fn render_add_account(
f: &mut Frame,
area: Rect,
name_input: &str,
_cursor_position: usize,
error: Option<&str>,
) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
// Input field
let input_display = if name_input.is_empty() {
Span::styled("_", Style::default().fg(Color::DarkGray))
} else {
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
};
lines.push(Line::from(vec![
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
input_display,
]));
// Hint
lines.push(Line::from(Span::styled(
" (a-z, 0-9, -, _)",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
// Error
if let Some(err) = error {
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
lines.push(Line::from(""));
}
// Help bar
lines.push(Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::styled("Create", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red)),
Span::styled("Back", Style::default().fg(Color::DarkGray)),
]));
let height = if error.is_some() { 10 } else { 8 };
let height = (height as u16).min(area.height.saturating_sub(4));
let width = 40u16.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(width)) / 2;
let y = area.y + (area.height.saturating_sub(height)) / 2;
let modal_area = Rect::new(x, y, width, height);
f.render_widget(Clear, modal_area);
let modal = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.title(" НОВЫЙ АККАУНТ ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(modal, modal_area);
}

View File

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

View File

@@ -0,0 +1,178 @@
//! Модальное окно для полноэкранного просмотра изображений.
//!
//! Поддерживает:
//! - Автоматическое масштабирование с сохранением aspect ratio
//! - Максимизация по ширине/высоте терминала
//! - Затемнение фона
//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото
use crate::app::App;
use crate::tdlib::r#trait::TdClientTrait;
use crate::tdlib::ImageModalState;
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph},
Frame,
};
use ratatui_image::StatefulImage;
/// Рендерит модальное окно с полноэкранным изображением
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>, modal_state: &ImageModalState) {
let area = f.area();
// Затемняем весь фон
f.render_widget(Clear, area);
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
// Резервируем место для подсказок (2 строки внизу)
let image_area_height = area.height.saturating_sub(2);
// Вычисляем размер изображения с сохранением aspect ratio
let (img_width, img_height) = calculate_modal_size(
modal_state.photo_width,
modal_state.photo_height,
area.width,
image_area_height,
);
// Центрируем изображение
let img_x = (area.width.saturating_sub(img_width)) / 2;
let img_y = (image_area_height.saturating_sub(img_height)) / 2;
let img_rect = Rect::new(img_x, img_y, img_width, img_height);
// Рендерим изображение (используем modal_renderer для высокого качества)
if let Some(renderer) = &mut app.modal_image_renderer {
// Проверяем есть ли протокол уже в кеше
if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) {
// Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество)
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
} else {
// Протокола нет - показываем индикатор загрузки
let loading_text = vec![
Line::from(""),
Line::from(Span::styled(
"⏳ Загрузка изображения...",
Style::default().fg(Color::Gray),
)),
Line::from(""),
Line::from(Span::styled(
"(декодирование в высоком качестве)",
Style::default().fg(Color::DarkGray),
)),
];
let loading = Paragraph::new(loading_text)
.alignment(Alignment::Center)
.block(Block::default());
f.render_widget(loading, img_rect);
// Загружаем изображение (может занять время для iTerm2/Sixel)
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
// Триггерим перерисовку для показа загруженного изображения
app.needs_redraw = true;
}
}
// Подсказки внизу
let hint = "[Esc/q] Закрыть [←/→] Пред/След фото";
let hint_y = area.height.saturating_sub(1);
let hint_rect = Rect::new(0, hint_y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray)))
.alignment(Alignment::Center),
hint_rect,
);
// Информация о размере (опционально)
let info = format!(
"{}x{} | {:.1}%",
modal_state.photo_width,
modal_state.photo_height,
(img_width as f64 / modal_state.photo_width as f64) * 100.0
);
let info_y = area.height.saturating_sub(2);
let info_rect = Rect::new(0, info_y, area.width, 1);
f.render_widget(
Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray)))
.alignment(Alignment::Center),
info_rect,
);
}
/// Вычисляет размер изображения для модалки с сохранением aspect ratio.
///
/// # Логика масштабирования:
/// - Если изображение меньше терминала → показываем как есть
/// - Если ширина больше → масштабируем по ширине
/// - Если высота больше → масштабируем по высоте
/// - Сохраняем aspect ratio
fn calculate_modal_size(
img_width: i32,
img_height: i32,
term_width: u16,
term_height: u16,
) -> (u16, u16) {
let aspect_ratio = img_width as f64 / img_height as f64;
// Если изображение помещается целиком
if img_width <= term_width as i32 && img_height <= term_height as i32 {
return (img_width as u16, img_height as u16);
}
// Начинаем с максимального размера терминала
let mut width = term_width as f64;
let mut height = term_height as f64;
// Подгоняем по aspect ratio
let term_aspect = width / height;
if term_aspect > aspect_ratio {
// Терминал шире → ограничены по высоте
width = height * aspect_ratio;
} else {
// Терминал выше → ограничены по ширине
height = width / aspect_ratio;
}
(width as u16, height as u16)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_modal_size_fits() {
// Изображение помещается целиком
let (w, h) = calculate_modal_size(50, 30, 100, 50);
assert_eq!(w, 50);
assert_eq!(h, 30);
}
#[test]
fn test_calculate_modal_size_scale_width() {
// Ограничены по ширине (изображение шире терминала)
let (w, h) = calculate_modal_size(200, 100, 100, 100);
assert_eq!(w, 100);
assert_eq!(h, 50); // aspect ratio 2:1
}
#[test]
fn test_calculate_modal_size_scale_height() {
// Ограничены по высоте (изображение выше терминала)
let (w, h) = calculate_modal_size(100, 200, 100, 100);
assert_eq!(w, 50); // aspect ratio 1:2
assert_eq!(h, 100);
}
#[test]
fn test_calculate_modal_size_aspect_ratio() {
// Проверка сохранения aspect ratio
let (w, h) = calculate_modal_size(1920, 1080, 100, 100);
let aspect = w as f64 / h as f64;
let expected_aspect = 1920.0 / 1080.0;
assert!((aspect - expected_aspect).abs() < 0.01);
}
}

View File

@@ -0,0 +1,27 @@
//! Modal dialog rendering modules
//!
//! Contains UI rendering for various modal dialogs:
//! - account_switcher: Account switcher modal (global overlay)
//! - delete_confirm: Delete confirmation modal
//! - reaction_picker: Emoji reaction picker modal
//! - search: Message search modal
//! - pinned: Pinned messages viewer modal
//! - image_viewer: Full-screen image viewer modal (images feature)
pub mod account_switcher;
pub mod delete_confirm;
pub mod pinned;
pub mod reaction_picker;
pub mod search;
#[cfg(feature = "images")]
pub mod image_viewer;
pub use account_switcher::render as render_account_switcher;
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 search::render as render_search;
#[cfg(feature = "images")]
pub use image_viewer::render as render_image_viewer;

View File

@@ -0,0 +1,91 @@
//! Pinned messages viewer modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) =
if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Magenta,
);
f.render_widget(help, chunks[2]);
}

View File

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

View File

@@ -0,0 +1,110 @@
//! Message search modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders message search mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat { query, results, selected_index } =
&app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 { selected_index + 1 } else { 0 };
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
}
}
// Скролл к выбранному результату
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("n/N", "след./пред.", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Yellow,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,287 @@
use crate::app::methods::modal::ModalMethods;
use crate::app::App;
use crate::tdlib::ProfileInfo;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Рендерит режим просмотра профиля
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, profile: &ProfileInfo) {
// Проверяем, показывать ли модалку подтверждения
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
render_leave_confirmation_modal(f, area, confirmation_step);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Profile info
Constraint::Length(3), // Actions help
])
.split(area);
// Header
let header_text = format!("👤 ПРОФИЛЬ: {}", profile.title);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Profile info
let mut lines: Vec<Line> = Vec::new();
// Тип чата
lines.push(Line::from(vec![
Span::styled("Тип: ", Style::default().fg(Color::Gray)),
Span::styled(&profile.chat_type, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// ID
lines.push(Line::from(vec![
Span::styled("ID: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// Username
if let Some(username) = &profile.username {
lines.push(Line::from(vec![
Span::styled("Username: ", Style::default().fg(Color::Gray)),
Span::styled(username, Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(""));
}
// Phone number (только для личных чатов)
if let Some(phone) = &profile.phone_number {
lines.push(Line::from(vec![
Span::styled("Телефон: ", Style::default().fg(Color::Gray)),
Span::styled(phone, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Online status (только для личных чатов)
if let Some(status) = &profile.online_status {
lines.push(Line::from(vec![
Span::styled("Статус: ", Style::default().fg(Color::Gray)),
Span::styled(status, Style::default().fg(Color::Green)),
]));
lines.push(Line::from(""));
}
// Bio (только для личных чатов)
if let Some(bio) = &profile.bio {
lines.push(Line::from(vec![Span::styled("О себе: ", Style::default().fg(Color::Gray))]));
// Разбиваем bio на строки если длинное
let bio_lines: Vec<&str> = bio.lines().collect();
for bio_line in bio_lines {
lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Member count (для групп/каналов)
if let Some(count) = profile.member_count {
lines.push(Line::from(vec![
Span::styled("Участников: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", count), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Description (для групп/каналов)
if let Some(desc) = &profile.description {
lines.push(Line::from(vec![Span::styled("Описание: ", Style::default().fg(Color::Gray))]));
let desc_lines: Vec<&str> = desc.lines().collect();
for desc_line in desc_lines {
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Invite link (для групп/каналов)
if let Some(link) = &profile.invite_link {
lines.push(Line::from(vec![
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
Span::styled(
link,
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED),
),
]));
lines.push(Line::from(""));
}
// Разделитель
lines.push(Line::from("────────────────────────────────"));
lines.push(Line::from(""));
// Действия
lines.push(Line::from(Span::styled(
"Действия:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let actions = get_available_actions(profile);
for (idx, action) in actions.iter().enumerate() {
let is_selected = idx == app.get_selected_profile_action().unwrap_or(0);
let marker = if is_selected { "" } else { " " };
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(marker, Style::default().fg(Color::Yellow)),
Span::styled(*action, style),
]));
}
let info_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.scroll((0, 0));
f.render_widget(info_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("выбрать"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Получить список доступных действий
fn get_available_actions(profile: &ProfileInfo) -> Vec<&'static str> {
let mut actions = vec![];
if profile.username.is_some() {
actions.push("Открыть в браузере");
}
actions.push("Скопировать ID");
if profile.is_group {
actions.push("Покинуть группу");
}
actions
}
/// Рендерит модалку подтверждения выхода из группы
fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
// Затемняем фон
let modal_area = centered_rect(60, 30, area);
let text = if step == 1 {
"Вы хотите выйти из группы?"
} else {
"Вы ТОЧНО хотите выйти из группы?!?!?"
};
let lines = vec![
Line::from(""),
Line::from(Span::styled(
text,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled(
"y/н/Enter",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" — да "),
Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw(" — нет"),
]),
];
let modal = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" ⚠ ВНИМАНИЕ ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
)
.alignment(Alignment::Center);
f.render_widget(modal, modal_area);
}
/// Вспомогательная функция для центрирования прямоугольника
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}

View File

@@ -0,0 +1,282 @@
#[cfg(test)]
use chrono::FixedOffset;
use chrono::{DateTime, Local, NaiveDate, Utc};
use std::time::{SystemTime, UNIX_EPOCH};
pub trait LocalTimeSource {
fn now_date(&self) -> NaiveDate;
fn now_timestamp(&self) -> i32;
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String>;
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate>;
}
pub struct SystemLocalTime;
impl LocalTimeSource for SystemLocalTime {
fn now_date(&self) -> NaiveDate {
Local::now().date_naive()
}
fn now_timestamp(&self) -> i32 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i32
}
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local).format(format).to_string())
}
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local).date_naive())
}
}
#[derive(Debug, Clone)]
#[cfg(test)]
pub struct FixedLocalTime {
offset: FixedOffset,
now: DateTime<FixedOffset>,
}
#[cfg(test)]
impl FixedLocalTime {
fn new(offset: FixedOffset, now_timestamp: i32) -> Self {
let now = DateTime::<Utc>::from_timestamp(now_timestamp as i64, 0)
.expect("valid fixed timestamp")
.with_timezone(&offset);
Self { offset, now }
}
}
#[cfg(test)]
impl LocalTimeSource for FixedLocalTime {
fn now_date(&self) -> NaiveDate {
self.now.date_naive()
}
fn now_timestamp(&self) -> i32 {
self.now.timestamp() as i32
}
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&self.offset).format(format).to_string())
}
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&self.offset).date_naive())
}
}
fn system_time() -> SystemLocalTime {
SystemLocalTime
}
/// Форматирование timestamp во время HH:MM в системной таймзоне.
pub fn format_timestamp(timestamp: i32) -> String {
format_timestamp_with(timestamp, &system_time())
}
pub fn format_timestamp_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
time.format_timestamp(timestamp, "%H:%M")
.unwrap_or_else(|| "00:00".to_string())
}
/// Форматирование timestamp в дату для разделителя.
pub fn format_date(timestamp: i32) -> String {
format_date_with(timestamp, &system_time())
}
pub fn format_date_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
let Some(msg_day) = time.date_for_timestamp(timestamp) else {
return "01.01.1970".to_string();
};
let today = time.now_date();
if msg_day == today {
"Сегодня".to_string()
} else if Some(msg_day) == today.pred_opt() {
"Вчера".to_string()
} else {
time.format_timestamp(timestamp, "%d.%m.%Y")
.unwrap_or_else(|| "01.01.1970".to_string())
}
}
/// Получить день из timestamp для группировки.
/// Возвращает число дней с 1970-01-01 в системной таймзоне.
#[allow(dead_code)]
pub fn get_day(timestamp: i32) -> i64 {
get_day_with(timestamp, &system_time())
}
#[allow(dead_code)]
pub fn get_day_with(timestamp: i32, time: &impl LocalTimeSource) -> i64 {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
time.date_for_timestamp(timestamp)
.map(|date| date.signed_duration_since(epoch).num_days())
.unwrap_or(0)
}
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) в системной таймзоне.
pub fn format_datetime(timestamp: i32) -> String {
format_datetime_with(timestamp, &system_time())
}
pub fn format_datetime_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
time.format_timestamp(timestamp, "%d.%m.%Y %H:%M")
.unwrap_or_else(|| "01.01.1970 00:00".to_string())
}
/// Форматирование "был(а) онлайн" из timestamp
pub fn format_was_online(timestamp: i32) -> String {
format_was_online_with(timestamp, &system_time())
}
pub fn format_was_online_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
let now = time.now_timestamp();
let diff = now - timestamp;
if diff < 60 {
"был(а) только что".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("был(а) {} мин. назад", mins)
} else if diff < 86400 {
let hours = diff / 3600;
format!("был(а) {} ч. назад", hours)
} else {
// Показываем локальную дату
let datetime = time
.format_timestamp(timestamp, "%d.%m %H:%M")
.unwrap_or_else(|| "давно".to_string());
format!("был(а) {}", datetime)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_time() -> FixedLocalTime {
FixedLocalTime::new(
FixedOffset::east_opt(3 * 3600).unwrap(),
1_640_448_000, // 25.12.2021 03:00:00 +03:00
)
}
#[test]
fn test_format_timestamp_uses_supplied_timezone() {
let timestamp = 1640000000;
assert_eq!(format_timestamp_with(timestamp, &fixed_time()), "14:33");
}
#[test]
fn test_get_day() {
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
assert_eq!(get_day_with(0, &time), 0);
assert_eq!(get_day_with(86400, &time), 1);
}
#[test]
fn test_get_day_grouping() {
let time = fixed_time();
let msg1 = 1640000000;
let msg2 = msg1 + 3600;
assert_eq!(get_day_with(msg1, &time), get_day_with(msg2, &time));
let msg3 = msg1 + 172800;
assert_ne!(get_day_with(msg1, &time), get_day_with(msg3, &time));
}
#[test]
fn test_format_datetime() {
let timestamp = 1640000000;
assert_eq!(format_datetime_with(timestamp, &fixed_time()), "20.12.2021 14:33");
}
#[test]
fn test_format_date_today() {
let time = fixed_time();
let result = format_date_with(time.now_timestamp(), &time);
assert_eq!(result, "Сегодня");
}
#[test]
fn test_format_date_yesterday() {
let time = fixed_time();
let yesterday = time.now_timestamp() - 86400;
let result = format_date_with(yesterday, &time);
assert_eq!(result, "Вчера");
}
#[test]
fn test_format_date_old() {
let old_timestamp = 1640000000;
assert_eq!(format_date_with(old_timestamp, &fixed_time()), "20.12.2021");
}
#[test]
fn test_format_date_epoch() {
let epoch = 0;
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
let result = format_date_with(epoch, &time);
assert!(result.contains('.'));
assert!(result.contains("1970"));
}
#[test]
fn test_format_was_online_just_now() {
let time = fixed_time();
let now = time.now_timestamp();
let recent = now - 30;
let result = format_was_online_with(recent, &time);
assert_eq!(result, "был(а) только что");
}
#[test]
fn test_format_was_online_minutes_ago() {
let time = fixed_time();
let now = time.now_timestamp();
let mins_ago = now - (15 * 60);
let result = format_was_online_with(mins_ago, &time);
assert_eq!(result, "был(а) 15 мин. назад");
}
#[test]
fn test_format_was_online_hours_ago() {
let time = fixed_time();
let now = time.now_timestamp();
let hours_ago = now - (5 * 3600);
let result = format_was_online_with(hours_ago, &time);
assert_eq!(result, "был(а) 5 ч. назад");
}
#[test]
fn test_format_was_online_days_ago() {
let time = fixed_time();
let now = time.now_timestamp();
let days_ago = now - (3 * 86400);
let result = format_was_online_with(days_ago, &time);
assert!(result.starts_with("был(а)"));
assert!(result.contains('.') || result.contains(':'));
}
#[test]
fn test_format_was_online_very_old() {
let old = 1577836800;
let result = format_was_online_with(old, &fixed_time());
assert!(result.starts_with("был(а)"));
assert!(result.contains('.'));
}
}

View File

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

View File

@@ -0,0 +1,86 @@
//! Modal dialog utilities
//!
//! Переиспользуемая логика для обработки модальных окон (диалогов).
use crossterm::event::KeyCode;
/// Обрабатывает клавиши для подтверждения Yes/No.
///
/// Поддерживает:
/// - `y` / `Y` / `д` / `Д` - да (confirm)
/// - `n` / `N` / `т` / `Т` - нет (close)
/// - `Enter` - подтвердить (confirm)
/// - `Esc` - отменить (close)
///
/// # Arguments
///
/// * `key_code` - код нажатой клавиши
///
/// # Returns
///
/// * `Some(true)` - подтверждение (yes/Enter)
/// * `Some(false)` - отмена (no/Escape)
/// * `None` - другая клавиша (продолжить ввод)
///
/// # Examples
///
/// ```
/// use crossterm::event::KeyCode;
/// use tele_tui::utils::modal_handler::handle_yes_no;
///
/// assert_eq!(handle_yes_no(KeyCode::Char('y')), Some(true));
/// assert_eq!(handle_yes_no(KeyCode::Char('Y')), Some(true));
/// assert_eq!(handle_yes_no(KeyCode::Char('н')), Some(true)); // русская 'y'
/// assert_eq!(handle_yes_no(KeyCode::Enter), Some(true));
///
/// assert_eq!(handle_yes_no(KeyCode::Char('n')), Some(false));
/// assert_eq!(handle_yes_no(KeyCode::Char('т')), Some(false)); // русская 'n'
/// assert_eq!(handle_yes_no(KeyCode::Esc), Some(false));
///
/// assert_eq!(handle_yes_no(KeyCode::Char('a')), None);
/// ```
pub fn handle_yes_no(key_code: KeyCode) -> Option<bool> {
match key_code {
// Yes - подтверждение (английская и русская раскладка)
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('н') | KeyCode::Char('Н') => {
Some(true)
}
KeyCode::Enter => Some(true),
// No - отмена (английская и русская раскладка)
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Char('т') | KeyCode::Char('Т') => {
Some(false)
}
KeyCode::Esc => Some(false),
// Другие клавиши - продолжить
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_handle_yes_no() {
// Yes variants
assert_eq!(handle_yes_no(KeyCode::Char('y')), Some(true));
assert_eq!(handle_yes_no(KeyCode::Char('Y')), Some(true));
assert_eq!(handle_yes_no(KeyCode::Char('н')), Some(true)); // Russian
assert_eq!(handle_yes_no(KeyCode::Char('Н')), Some(true)); // Russian
assert_eq!(handle_yes_no(KeyCode::Enter), Some(true));
// No variants
assert_eq!(handle_yes_no(KeyCode::Char('n')), Some(false));
assert_eq!(handle_yes_no(KeyCode::Char('N')), Some(false));
assert_eq!(handle_yes_no(KeyCode::Char('т')), Some(false)); // Russian
assert_eq!(handle_yes_no(KeyCode::Char('Т')), Some(false)); // Russian
assert_eq!(handle_yes_no(KeyCode::Esc), Some(false));
// Other keys
assert_eq!(handle_yes_no(KeyCode::Char('a')), None);
assert_eq!(handle_yes_no(KeyCode::Up), None);
assert_eq!(handle_yes_no(KeyCode::Char(' ')), None);
}
}

View File

@@ -0,0 +1,167 @@
use std::future::Future;
use std::time::Duration;
use tokio::time::timeout;
/// Выполняет операцию с таймаутом и возвращает результат.
///
/// # Arguments
///
/// * `duration` - Длительность таймаута
/// * `operation` - Асинхронная операция для выполнения
///
/// # Returns
///
/// * `Ok(T)` - если операция успешна
/// * `Err(String)` - если операция вернула ошибку или произошел таймаут
///
/// # Examples
///
/// ```ignore
/// let result = with_timeout(
/// Duration::from_secs(5),
/// client.load_chats(50)
/// ).await;
/// ```
pub async fn with_timeout<F, T>(duration: Duration, operation: F) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
match timeout(duration, operation).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(e),
Err(_) => Err("Операция превысила время ожидания".to_string()),
}
}
/// Выполняет операцию с таймаутом и кастомным сообщением об ошибке таймаута.
///
/// # Arguments
///
/// * `duration` - Длительность таймаута
/// * `operation` - Асинхронная операция для выполнения
/// * `timeout_msg` - Сообщение об ошибке при таймауте
///
/// # Returns
///
/// * `Ok(T)` - если операция успешна
/// * `Err(String)` - если операция вернула ошибку или произошел таймаут
///
/// # Examples
///
/// ```ignore
/// let result = with_timeout_msg(
/// Duration::from_secs(5),
/// client.load_chats(50),
/// "Таймаут загрузки чатов"
/// ).await;
/// ```
pub async fn with_timeout_msg<F, T>(
duration: Duration,
operation: F,
timeout_msg: &str,
) -> Result<T, String>
where
F: Future<Output = Result<T, String>>,
{
match timeout(duration, operation).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(e),
Err(_) => Err(timeout_msg.to_string()),
}
}
/// Выполняет операцию с таймаутом, игнорируя результат и ошибки.
///
/// Используется для не критичных операций (например, загрузка дополнительных данных),
/// где таймаут или ошибка не должны прерывать основной flow.
///
/// Работает как с Result<T, E>, так и с void операциями.
///
/// # Arguments
///
/// * `duration` - Длительность таймаута
/// * `operation` - Асинхронная операция для выполнения
///
/// # Examples
///
/// ```ignore
/// // Загружаем reply info, но не ждём если долго
/// with_timeout_ignore(
/// Duration::from_secs(5),
/// client.fetch_missing_reply_info()
/// ).await;
/// ```
pub async fn with_timeout_ignore<F>(duration: Duration, operation: F)
where
F: Future,
{
let _ = timeout(duration, operation).await;
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn test_with_timeout_success() {
let result =
with_timeout(Duration::from_secs(1), async { Ok::<_, String>("success".to_string()) })
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
}
#[tokio::test]
async fn test_with_timeout_operation_error() {
let result = with_timeout(Duration::from_secs(1), async {
Err::<String, _>("operation failed".to_string())
})
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "operation failed");
}
#[tokio::test]
async fn test_with_timeout_timeout_error() {
let result = with_timeout(Duration::from_millis(10), async {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, String>("too slow".to_string())
})
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("превысила время ожидания"));
}
#[tokio::test]
async fn test_with_timeout_msg_success() {
let result = with_timeout_msg(
Duration::from_secs(1),
async { Ok::<_, String>("success".to_string()) },
"Custom timeout",
)
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
}
#[tokio::test]
async fn test_with_timeout_msg_timeout_error() {
let result = with_timeout_msg(
Duration::from_millis(10),
async {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, String>("too slow".to_string())
},
"Таймаут загрузки",
)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "Таймаут загрузки");
}
}

View File

@@ -0,0 +1,25 @@
use std::ffi::CString;
use std::os::raw::c_char;
#[link(name = "tdjson")]
extern "C" {
fn td_execute(request: *const c_char) -> *const c_char;
}
/// Отключаем логи TDLib синхронно, до создания клиента
pub fn disable_tdlib_logs() {
let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#;
if let Ok(c_request) = CString::new(request) {
unsafe {
let _ = td_execute(c_request.as_ptr());
}
}
// Также перенаправляем логи в никуда
let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#;
if let Ok(c_request2) = CString::new(request2) {
unsafe {
let _ = td_execute(c_request2.as_ptr());
}
}
}

View File

@@ -0,0 +1,33 @@
//! Input validation utilities
//!
//! Переиспользуемые валидаторы для проверки пользовательского ввода.
/// Проверяет, что строка не пустая (после trim).
///
/// # Examples
///
/// ```
/// use tele_tui::utils::validation::is_non_empty;
///
/// assert!(is_non_empty("hello"));
/// assert!(is_non_empty(" text "));
/// assert!(!is_non_empty(""));
/// assert!(!is_non_empty(" "));
/// ```
pub fn is_non_empty(text: &str) -> bool {
!text.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_non_empty() {
assert!(is_non_empty("hello"));
assert!(is_non_empty(" text "));
assert!(!is_non_empty(""));
assert!(!is_non_empty(" "));
assert!(!is_non_empty("\t\n"));
}
}

View File

@@ -0,0 +1,192 @@
// Integration tests for account switcher modal
mod helpers;
use helpers::app_builder::TestAppBuilder;
use tele_tui::app::AccountSwitcherState;
// ============ Open/Close Tests ============
#[test]
fn test_open_account_switcher() {
let mut app = TestAppBuilder::new().build();
assert!(app.account_switcher.is_none());
app.open_account_switcher();
assert!(app.account_switcher.is_some());
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { accounts, selected_index, current_account }) => {
assert!(!accounts.is_empty());
assert_eq!(*selected_index, 0);
assert_eq!(current_account, "default");
}
_ => panic!("Expected SelectAccount state"),
}
}
#[test]
fn test_close_account_switcher() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
assert!(app.account_switcher.is_some());
app.close_account_switcher();
assert!(app.account_switcher.is_none());
}
// ============ Navigation Tests ============
#[test]
fn test_account_switcher_navigate_down() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
let num_accounts = match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(),
_ => panic!("Expected SelectAccount state"),
};
// Navigate down past all accounts to "Add account" item
for _ in 0..num_accounts {
app.account_switcher_select_next();
}
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { selected_index, accounts, .. }) => {
// Should be at the "Add account" item (index == accounts.len())
assert_eq!(*selected_index, accounts.len());
}
_ => panic!("Expected SelectAccount state"),
}
}
#[test]
fn test_account_switcher_navigate_up() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
// Navigate down first
app.account_switcher_select_next();
// Navigate back up
app.account_switcher_select_prev();
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => {
assert_eq!(*selected_index, 0);
}
_ => panic!("Expected SelectAccount state"),
}
}
#[test]
fn test_account_switcher_navigate_up_at_top() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
// Already at 0, navigate up should stay at 0
app.account_switcher_select_prev();
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => {
assert_eq!(*selected_index, 0);
}
_ => panic!("Expected SelectAccount state"),
}
}
// ============ Confirm Tests ============
#[test]
fn test_confirm_current_account_closes_modal() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
// Confirm on the current account (default) should just close
app.account_switcher_confirm();
assert!(app.account_switcher.is_none());
assert!(app.pending_account_switch.is_none());
}
#[test]
fn test_confirm_add_account_transitions_to_add_state() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
let num_accounts = match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(),
_ => panic!("Expected SelectAccount state"),
};
// Navigate past all accounts to "+ Add account"
for _ in 0..num_accounts {
app.account_switcher_select_next();
}
// Confirm should transition to AddAccount
app.account_switcher_confirm();
match &app.account_switcher {
Some(AccountSwitcherState::AddAccount { name_input, cursor_position, error }) => {
assert!(name_input.is_empty());
assert_eq!(*cursor_position, 0);
assert!(error.is_none());
}
_ => panic!("Expected AddAccount state"),
}
}
// ============ Add Account State Tests ============
#[test]
fn test_start_add_from_select() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
// Use quick shortcut
app.account_switcher_start_add();
match &app.account_switcher {
Some(AccountSwitcherState::AddAccount { .. }) => {}
_ => panic!("Expected AddAccount state"),
}
}
#[test]
fn test_back_from_add_to_select() {
let mut app = TestAppBuilder::new().build();
app.open_account_switcher();
app.account_switcher_start_add();
// Go back
app.account_switcher_back();
match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { .. }) => {}
_ => panic!("Expected SelectAccount state after back"),
}
}
// ============ Footer Tests ============
#[test]
fn test_default_account_name() {
let app = TestAppBuilder::new().build();
assert_eq!(app.current_account_name, "default");
}
#[test]
fn test_custom_account_name() {
let mut app = TestAppBuilder::new().build();
app.current_account_name = "work".to_string();
assert_eq!(app.current_account_name, "work");
}
// ============ Pending Switch Tests ============
#[test]
fn test_pending_switch_initially_none() {
let app = TestAppBuilder::new().build();
assert!(app.pending_account_switch.is_none());
}

View File

@@ -0,0 +1,180 @@
// Integration tests for accounts module
use tele_tui::accounts::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
#[test]
fn test_default_single_config() {
let config = AccountsConfig::default_single();
assert_eq!(config.default_account, "default");
assert_eq!(config.accounts.len(), 1);
assert_eq!(config.accounts[0].name, "default");
assert_eq!(config.accounts[0].display_name, "Default");
}
#[test]
fn test_find_account_exists() {
let config = AccountsConfig::default_single();
let account = config.find_account("default");
assert!(account.is_some());
assert_eq!(account.unwrap().name, "default");
}
#[test]
fn test_find_account_not_found() {
let config = AccountsConfig::default_single();
assert!(config.find_account("work").is_none());
assert!(config.find_account("").is_none());
}
#[test]
fn test_db_path_structure() {
let path = account_db_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("tdlib_data"));
}
#[test]
fn test_db_path_per_account() {
let path_default = account_db_path("default");
let path_work = account_db_path("work");
assert_ne!(path_default, path_work);
assert!(path_default.to_string_lossy().contains("default"));
assert!(path_work.to_string_lossy().contains("work"));
}
#[test]
fn test_account_profile_db_path() {
let profile = AccountProfile {
name: "test-account".to_string(),
display_name: "Test".to_string(),
};
let path = profile.db_path();
assert!(path.to_string_lossy().contains("test-account"));
assert!(path.to_string_lossy().ends_with("tdlib_data"));
}
#[test]
fn test_validate_account_name_valid() {
assert!(validate_account_name("default").is_ok());
assert!(validate_account_name("work").is_ok());
assert!(validate_account_name("my-account").is_ok());
assert!(validate_account_name("account123").is_ok());
assert!(validate_account_name("test_account").is_ok());
assert!(validate_account_name("a").is_ok());
}
#[test]
fn test_validate_account_name_empty() {
let err = validate_account_name("").unwrap_err();
assert!(err.contains("empty"));
}
#[test]
fn test_validate_account_name_too_long() {
let long_name = "a".repeat(33);
let err = validate_account_name(&long_name).unwrap_err();
assert!(err.contains("32"));
}
#[test]
fn test_validate_account_name_uppercase() {
assert!(validate_account_name("MyAccount").is_err());
assert!(validate_account_name("WORK").is_err());
}
#[test]
fn test_validate_account_name_spaces() {
assert!(validate_account_name("my account").is_err());
}
#[test]
fn test_validate_account_name_starts_with_dash() {
assert!(validate_account_name("-bad").is_err());
}
#[test]
fn test_validate_account_name_starts_with_underscore() {
assert!(validate_account_name("_bad").is_err());
}
#[test]
fn test_validate_account_name_special_chars() {
assert!(validate_account_name("foo@bar").is_err());
assert!(validate_account_name("foo.bar").is_err());
assert!(validate_account_name("foo/bar").is_err());
}
#[test]
fn test_resolve_account_default() {
let config = AccountsConfig::default_single();
let result = tele_tui::accounts::resolve_account(&config, None);
assert!(result.is_ok());
let (name, path) = result.unwrap();
assert_eq!(name, "default");
assert!(path.to_string_lossy().contains("default"));
}
#[test]
fn test_resolve_account_explicit() {
let config = AccountsConfig::default_single();
let result = tele_tui::accounts::resolve_account(&config, Some("default"));
assert!(result.is_ok());
let (name, _) = result.unwrap();
assert_eq!(name, "default");
}
#[test]
fn test_resolve_account_not_found() {
let config = AccountsConfig::default_single();
let result = tele_tui::accounts::resolve_account(&config, Some("work"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("work"));
assert!(err.contains("not found"));
}
#[test]
fn test_resolve_account_invalid_name() {
let config = AccountsConfig::default_single();
let result = tele_tui::accounts::resolve_account(&config, Some("BAD NAME"));
assert!(result.is_err());
}
#[test]
fn test_accounts_config_serde_roundtrip() {
let config = AccountsConfig::default_single();
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.default_account, config.default_account);
assert_eq!(parsed.accounts.len(), config.accounts.len());
assert_eq!(parsed.accounts[0].name, config.accounts[0].name);
}
#[test]
fn test_accounts_config_multi_account_serde() {
let config = AccountsConfig {
default_account: "default".to_string(),
accounts: vec![
AccountProfile {
name: "default".to_string(),
display_name: "Default".to_string(),
},
AccountProfile {
name: "work".to_string(),
display_name: "Work".to_string(),
},
],
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.accounts.len(), 2);
assert!(parsed.find_account("work").is_some());
}

View File

@@ -0,0 +1,505 @@
// Chat list UI snapshot tests
mod helpers;
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
use insta::assert_snapshot;
#[test]
fn snapshot_empty_chat_list() {
let mut app = TestAppBuilder::new().build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("empty_chat_list", output);
}
#[test]
fn snapshot_chat_list_with_three_chats() {
let chat1 = create_test_chat("Mom", 123);
let chat2 = create_test_chat("Boss", 456);
let chat3 = create_test_chat("Rust Community", 789);
let mut app = TestAppBuilder::new()
.with_chats(vec![chat1, chat2, chat3])
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_list_three_chats", output);
}
#[test]
fn snapshot_chat_with_unread_count() {
let chat = TestChatBuilder::new("Mom", 123)
.unread_count(5)
.last_message("Привет, как дела?")
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_with_unread", output);
}
#[test]
fn test_incoming_message_shows_unread_badge() {
// Создаём чат БЕЗ непрочитанных сообщений
let chat = TestChatBuilder::new("Friend", 999)
.unread_count(0)
.last_message("Как дела?")
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
// Рендерим UI - должно быть без "(1)"
let buffer_before = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output_before = buffer_to_string(&buffer_before);
// Проверяем что нет "(1)" в первой строке чата
assert!(!output_before.contains("(1)"), "Before: should not contain (1)");
// Симулируем входящее сообщение - обновляем unread_count
app.chats[0].unread_count = 1;
app.chats[0].last_message = "Привет!".to_string();
// Рендерим UI снова - теперь должно быть "(1)"
let buffer_after = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output_after = buffer_to_string(&buffer_after);
// Проверяем что появилось "(1)" в первой строке чата
assert!(
output_after.contains("(1)"),
"After: should contain (1)\nActual output:\n{}",
output_after
);
}
#[tokio::test]
async fn test_opening_chat_clears_unread_badge() {
use helpers::test_data::TestMessageBuilder;
use tele_tui::types::{ChatId, MessageId};
// Создаём чат с 3 непрочитанными сообщениями
let chat = TestChatBuilder::new("Friend", 999)
.unread_count(3)
.last_message("У тебя 3 новых сообщения")
.build();
// Создаём 3 входящих сообщения (по умолчанию is_outgoing = false)
let messages = vec![
TestMessageBuilder::new("Привет!", 1)
.sender("Friend")
.build(),
TestMessageBuilder::new("Как дела?", 2)
.sender("Friend")
.build(),
TestMessageBuilder::new("Ответь мне!", 3)
.sender("Friend")
.build(),
];
let mut app = TestAppBuilder::new()
.with_chat(chat)
.with_messages(999, messages)
.build();
// Рендерим UI - должно быть "(3)"
let buffer_before = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output_before = buffer_to_string(&buffer_before);
// Проверяем что есть "(3)" в списке чатов
assert!(
output_before.contains("(3)"),
"Before opening: should contain (3)\nActual output:\n{}",
output_before
);
// Симулируем открытие чата - загружаем историю
let chat_id = ChatId::new(999);
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
// Собираем ID входящих сообщений (как в реальном коде)
let incoming_message_ids: Vec<MessageId> = loaded_messages
.iter()
.filter(|msg| !msg.is_outgoing())
.map(|msg| msg.id())
.collect();
// Проверяем что нашли 3 входящих сообщения
assert_eq!(incoming_message_ids.len(), 3, "Should have 3 incoming messages");
// Добавляем в очередь для отметки как прочитанные (напрямую через Mutex)
app.td_client
.pending_view_messages
.lock()
.unwrap()
.push((chat_id, incoming_message_ids));
// Обрабатываем очередь (как в main loop)
app.td_client.process_pending_view_messages().await;
// В FakeTdClient это должно записаться в 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[0].0, 999, "Should be for chat 999");
assert_eq!(viewed[0].1.len(), 3, "Should have viewed 3 messages");
// В реальном приложении TDLib отправит Update::ChatReadInbox
// который обновит unread_count в чате. Симулируем это:
app.chats[0].unread_count = 0;
// Рендерим UI снова - "(3)" должно пропасть
let buffer_after = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output_after = buffer_to_string(&buffer_after);
// Проверяем что "(3)" больше нет
assert!(
!output_after.contains("(3)"),
"After opening: should not contain (3)\nActual output:\n{}",
output_after
);
}
#[tokio::test]
async fn test_opening_chat_loads_many_messages() {
use helpers::test_data::TestMessageBuilder;
use tele_tui::types::ChatId;
// Создаём чат с 50 сообщениями
let chat = TestChatBuilder::new("History Chat", 888)
.last_message("Message 50")
.build();
// Создаём 50 сообщений
let messages: Vec<_> = (1..=50)
.map(|i| {
TestMessageBuilder::new(&format!("Message {}", i), i)
.sender("Friend")
.build()
})
.collect();
let app = TestAppBuilder::new()
.with_chat(chat)
.with_messages(888, messages)
.build();
// Открываем чат - загружаем историю (запрашиваем 100 сообщений)
let chat_id = ChatId::new(888);
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
// Проверяем что загрузились ВСЕ 50 сообщений, а не только последние 2-3
assert_eq!(
loaded_messages.len(),
50,
"Should load all 50 messages, not just last few. Got: {}",
loaded_messages.len()
);
// Проверяем что сообщения в правильном порядке (от старых к новым)
assert_eq!(loaded_messages[0].text(), "Message 1");
assert_eq!(loaded_messages[24].text(), "Message 25");
assert_eq!(loaded_messages[49].text(), "Message 50");
}
#[tokio::test]
async fn test_chat_history_chunked_loading() {
use tele_tui::types::ChatId;
// Создаём чат с 120 сообщениями (больше чем TDLIB_MESSAGE_LIMIT = 50)
let chat = TestChatBuilder::new("Long History Chat", 999)
.last_message("Message 120")
.build();
// Создаём 120 сообщений
let messages: Vec<_> = (1..=120)
.map(|i| {
TestMessageBuilder::new(&format!("Message {}", i), i)
.sender("Friend")
.build()
})
.collect();
let app = TestAppBuilder::new()
.with_chat(chat)
.with_messages(999, messages)
.build();
// Тест 1: Загружаем 100 сообщений (больше чем 50, меньше чем 120)
let chat_id = ChatId::new(999);
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
assert_eq!(
loaded_messages.len(),
100,
"Should load 100 messages with chunked loading. Got: {}",
loaded_messages.len()
);
// Проверяем что сообщения в правильном порядке (от старых к новым)
assert_eq!(loaded_messages[0].text(), "Message 1");
assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка
assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка
assert_eq!(loaded_messages[99].text(), "Message 100");
// Тест 2: Загружаем все 120 сообщений
let all_messages = app.td_client.get_chat_history(chat_id, 120).await.unwrap();
assert_eq!(
all_messages.len(),
120,
"Should load all 120 messages. Got: {}",
all_messages.len()
);
assert_eq!(all_messages[0].text(), "Message 1");
assert_eq!(all_messages[119].text(), "Message 120");
// Тест 3: Запрашиваем 200 сообщений, но есть только 120
let limited_messages = app.td_client.get_chat_history(chat_id, 200).await.unwrap();
assert_eq!(
limited_messages.len(),
120,
"Should load only available 120 messages when requesting 200. Got: {}",
limited_messages.len()
);
}
#[tokio::test]
async fn test_chat_history_loads_all_without_limit() {
use tele_tui::types::ChatId;
// Создаём чат с 200 сообщениями (4 чанка по 50)
let chat = TestChatBuilder::new("Very Long Chat", 1001)
.last_message("Message 200")
.build();
let messages: Vec<_> = (1..=200)
.map(|i| {
TestMessageBuilder::new(&format!("Msg {}", i), i)
.sender("User")
.build()
})
.collect();
let app = TestAppBuilder::new()
.with_chat(chat)
.with_messages(1001, messages)
.build();
// Загружаем без лимита (i32::MAX)
let chat_id = ChatId::new(1001);
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[0].text(), "Msg 1", "First message should be oldest");
assert_eq!(all[199].text(), "Msg 200", "Last message should be newest");
}
#[tokio::test]
async fn test_load_older_messages_pagination() {
use tele_tui::types::ChatId;
// Создаём чат со 150 сообщениями
let chat = TestChatBuilder::new("Paginated Chat", 1002)
.last_message("Message 150")
.build();
let messages: Vec<_> = (1..=150)
.map(|i| {
TestMessageBuilder::new(&format!("Msg {}", i), i)
.sender("User")
.build()
})
.collect();
let app = TestAppBuilder::new()
.with_chat(chat)
.with_messages(1002, messages)
.build();
let chat_id = ChatId::new(1002);
// Шаг 1: Загружаем только последние 30 сообщений
// get_chat_history загружает от конца, поэтому получим сообщения 1-30
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[0].text(), "Msg 1", "First message should be Msg 1");
assert_eq!(initial_batch[29].text(), "Msg 30", "Last should be Msg 30");
// Шаг 2: Загружаем все 150 сообщений для проверки load_older
let all_messages = app.td_client.get_chat_history(chat_id, 150).await.unwrap();
assert_eq!(all_messages.len(), 150);
// Имитируем ситуацию: у нас есть сообщения 101-150, хотим загрузить 51-100
// Берем ID сообщения 101 (первое в нашем "окне")
let msg_101_id = all_messages[100].id(); // index 100 = Msg 101
// Загружаем сообщения старше 101
let older_batch = app
.td_client
.load_older_messages(chat_id, msg_101_id)
.await
.unwrap();
// Должны получить сообщения 1-100 (все что старше 101)
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[99].text(), "Msg 100", "Newest in batch should be Msg 100");
}
#[test]
fn snapshot_chat_with_pinned() {
let chat = TestChatBuilder::new("Important Chat", 123)
.pinned()
.last_message("Pinned message")
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_pinned", output);
}
#[test]
fn snapshot_chat_with_muted() {
let chat = TestChatBuilder::new("Spam Group", 123)
.muted()
.unread_count(99)
.last_message("Too many messages")
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_muted", output);
}
#[test]
fn snapshot_chat_with_mentions() {
let chat = TestChatBuilder::new("Work Group", 123)
.unread_count(10)
.unread_mentions(2)
.last_message("@me check this out")
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_with_mentions", output);
}
#[test]
fn snapshot_selected_chat() {
let chat1 = create_test_chat("Mom", 123);
let chat2 = create_test_chat("Boss", 456);
let mut app = TestAppBuilder::new()
.with_chats(vec![chat1, chat2])
.selected_chat(123)
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_selected", output);
}
#[test]
fn snapshot_chat_long_title() {
let chat = TestChatBuilder::new("Very Long Chat Title That Should Be Truncated", 123)
.last_message("Test message")
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_long_title", output);
}
#[test]
fn snapshot_chat_search_mode() {
let chat1 = create_test_chat("Mom", 123);
let chat2 = create_test_chat("Boss", 456);
let chat3 = create_test_chat("Rust Community", 789);
let mut app = TestAppBuilder::new()
.with_chats(vec![chat1, chat2, chat3])
.searching("Mom")
.build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_list_search_mode", output);
}
#[test]
fn snapshot_chat_with_online_status() {
let chat = TestChatBuilder::new("Alice", 123)
.last_message("Hey there!")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.selected_chat(123)
.build();
// Note: Online status setup removed due to trait-based DI
// User status is not critical for this UI snapshot test
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
});
let output = buffer_to_string(&buffer);
assert_snapshot!("chat_with_online_status", output);
}

View File

@@ -0,0 +1,231 @@
// Integration tests for config flow
use tele_tui::config::{
AudioConfig, ColorsConfig, Config, ImagesConfig, Keybindings, NotificationsConfig,
};
/// Test: Дефолтные значения конфигурации
#[test]
fn test_config_default_values() {
let config = Config::default();
// Проверяем дефолтные цвета
assert_eq!(config.colors.incoming_message, "white");
assert_eq!(config.colors.outgoing_message, "green");
assert_eq!(config.colors.selected_message, "yellow");
assert_eq!(config.colors.reaction_chosen, "yellow");
assert_eq!(config.colors.reaction_other, "gray");
}
/// Test: Создание конфига с кастомными значениями
#[test]
fn test_config_custom_values() {
let config = Config {
colors: ColorsConfig {
incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(),
selected_message: "red".to_string(),
reaction_chosen: "green".to_string(),
reaction_other: "white".to_string(),
},
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
};
assert_eq!(config.colors.incoming_message, "cyan");
assert_eq!(config.colors.outgoing_message, "blue");
}
/// Test: Парсинг валидных цветов
#[test]
fn test_parse_valid_colors() {
use ratatui::style::Color;
let config = Config::default();
assert_eq!(config.parse_color("red"), Color::Red);
assert_eq!(config.parse_color("green"), Color::Green);
assert_eq!(config.parse_color("blue"), Color::Blue);
assert_eq!(config.parse_color("yellow"), Color::Yellow);
assert_eq!(config.parse_color("cyan"), Color::Cyan);
assert_eq!(config.parse_color("magenta"), Color::Magenta);
assert_eq!(config.parse_color("white"), Color::White);
assert_eq!(config.parse_color("black"), Color::Black);
assert_eq!(config.parse_color("gray"), Color::Gray);
assert_eq!(config.parse_color("grey"), Color::Gray);
}
/// Test: Парсинг light цветов
#[test]
fn test_parse_light_colors() {
use ratatui::style::Color;
let config = Config::default();
assert_eq!(config.parse_color("lightred"), Color::LightRed);
assert_eq!(config.parse_color("lightgreen"), Color::LightGreen);
assert_eq!(config.parse_color("lightblue"), Color::LightBlue);
assert_eq!(config.parse_color("lightyellow"), Color::LightYellow);
assert_eq!(config.parse_color("lightcyan"), Color::LightCyan);
assert_eq!(config.parse_color("lightmagenta"), Color::LightMagenta);
}
/// Test: Парсинг невалидного цвета использует fallback (White)
#[test]
fn test_parse_invalid_color_fallback() {
use ratatui::style::Color;
let config = Config::default();
// Невалидные цвета должны возвращать White
assert_eq!(config.parse_color("invalid_color"), Color::White);
assert_eq!(config.parse_color(""), Color::White);
assert_eq!(config.parse_color("purple"), Color::White); // purple не поддерживается
assert_eq!(config.parse_color("Orange"), Color::White); // orange не поддерживается
}
/// Test: Case-insensitive парсинг цветов
#[test]
fn test_parse_color_case_insensitive() {
use ratatui::style::Color;
let config = Config::default();
assert_eq!(config.parse_color("RED"), Color::Red);
assert_eq!(config.parse_color("Green"), Color::Green);
assert_eq!(config.parse_color("BLUE"), Color::Blue);
assert_eq!(config.parse_color("YeLLoW"), Color::Yellow);
}
/// Test: Сериализация и десериализация TOML
#[test]
fn test_config_toml_serialization() {
let original_config = Config {
colors: ColorsConfig {
incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(),
selected_message: "red".to_string(),
reaction_chosen: "green".to_string(),
reaction_other: "white".to_string(),
},
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
};
// Сериализуем в TOML
let toml_string = toml::to_string(&original_config).expect("Failed to serialize config");
// Десериализуем обратно
let deserialized: Config = toml::from_str(&toml_string).expect("Failed to deserialize config");
// Проверяем что всё совпадает
assert_eq!(deserialized.colors.incoming_message, "cyan");
assert_eq!(deserialized.colors.outgoing_message, "blue");
assert_eq!(deserialized.colors.selected_message, "red");
}
/// Test: Парсинг TOML с частичными данными использует дефолты
#[test]
fn test_config_partial_toml_uses_defaults() {
// TOML только с colors.incoming_message
let toml_str = r#"
[colors]
incoming_message = "cyan"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse partial TOML");
// Кастомный цвет должен примениться
assert_eq!(config.colors.incoming_message, "cyan");
// Остальные colors должны быть дефолтными
assert_eq!(config.colors.outgoing_message, "green");
assert_eq!(config.colors.selected_message, "yellow");
}
#[cfg(test)]
mod credentials_tests {
use super::*;
use std::env;
/// Test: Загрузка credentials из переменных окружения
#[test]
fn test_load_credentials_from_env() {
// Устанавливаем env переменные для теста
unsafe {
env::set_var("API_ID", "12345");
env::set_var("API_HASH", "test_hash_from_env");
}
// Загружаем credentials
let result = Config::load_credentials();
// Проверяем что загрузилось из env
// Примечание: этот тест может зафейлиться если есть credentials файл,
// так как он имеет приоритет. Для полноценного тестирования нужно
// моковать файловую систему или использовать временные директории.
if let Ok((api_id, api_hash)) = result {
// Может быть либо из файла, либо из env
assert!(api_id > 0);
assert!(!api_hash.is_empty());
}
// Очищаем env переменные после теста
unsafe {
env::remove_var("API_ID");
env::remove_var("API_HASH");
}
}
/// Test: Проверка формата ошибки когда credentials не найдены
#[test]
fn test_load_credentials_error_message() {
// Проверяем есть ли credentials файл в системе
let has_credentials_file = Config::credentials_path()
.map(|p| p.exists())
.unwrap_or(false);
// Если есть credentials файл, тест не может проверить ошибку
if has_credentials_file {
// Просто проверяем что credentials загружаются
let result = Config::load_credentials();
assert!(result.is_ok(), "Credentials file exists but loading failed");
return;
}
// Временно сохраняем и удаляем env переменные
let original_api_id = env::var("API_ID").ok();
let original_api_hash = env::var("API_HASH").ok();
unsafe {
env::remove_var("API_ID");
env::remove_var("API_HASH");
}
// Пытаемся загрузить credentials без файла и без env
let result = Config::load_credentials();
// Должна быть ошибка
if let Err(err_msg) = result {
// Проверяем формат ошибки
assert!(!err_msg.is_empty(), "Error message should not be empty");
} else {
// Возможно env переменные установлены глобально и не удаляются
// Тест пропускается
eprintln!("Warning: credentials loaded despite removing env vars");
}
// Восстанавливаем env переменные
unsafe {
if let Some(api_id) = original_api_id {
env::set_var("API_ID", api_id);
}
if let Some(api_hash) = original_api_hash {
env::set_var("API_HASH", api_hash);
}
}
}
}

View File

@@ -0,0 +1,174 @@
// Integration tests for copy message flow
mod helpers;
use helpers::test_data::TestMessageBuilder;
/// Test: Форматирование простого сообщения для копирования
#[test]
fn test_format_plain_message() {
let msg = TestMessageBuilder::new("Hello, world!", 1)
.sender("Alice")
.outgoing()
.build();
// Простое сообщение должно содержать только текст
let formatted = format_message_for_test(&msg);
assert_eq!(formatted, "Hello, world!");
}
/// Test: Форматирование сообщения с forward контекстом
#[test]
fn test_format_message_with_forward() {
let msg = TestMessageBuilder::new("Forwarded message", 1)
.sender("Bob")
.forwarded_from("Alice")
.build();
// Сообщение с forward должно содержать контекст
let formatted = format_message_for_test(&msg);
assert!(formatted.contains("↪ Переслано от Alice"));
assert!(formatted.contains("Forwarded message"));
}
/// Test: Форматирование сообщения с reply контекстом
#[test]
fn test_format_message_with_reply() {
let reply_msg = TestMessageBuilder::new("Reply text", 2)
.sender("Bob")
.reply_to(1, "Alice", "Original message")
.build();
// Сообщение с reply должно содержать контекст оригинала
let formatted = format_message_for_test(&reply_msg);
assert!(formatted.contains("┌ Alice: Original message"));
assert!(formatted.contains("Reply text"));
}
/// Test: Форматирование сообщения с forward и reply одновременно
#[test]
fn test_format_message_with_both_contexts() {
// Создаём сообщение с reply и forward
let msg = TestMessageBuilder::new("Complex message", 2)
.sender("Bob")
.reply_to(1, "Alice", "Original")
.forwarded_from("Charlie")
.build();
let formatted = format_message_for_test(&msg);
// Должны быть оба контекста
assert!(formatted.contains("↪ Переслано от Charlie"));
assert!(formatted.contains("┌ Alice: Original"));
assert!(formatted.contains("Complex message"));
}
/// Test: Форматирование длинного сообщения
#[test]
fn test_format_long_message() {
let long_text = "This is a very long message that spans multiple lines. ".repeat(10);
let msg = TestMessageBuilder::new(&long_text, 1)
.sender("Alice")
.build();
let formatted = format_message_for_test(&msg);
assert_eq!(formatted, long_text);
}
/// Test: Форматирование сообщения с markdown entities
#[test]
fn test_format_message_with_markdown() {
// Этот тест проверяет что entities сохраняются при копировании
// В реальном коде entities конвертируются в markdown
let msg = TestMessageBuilder::new("Bold text", 1)
.sender("Alice")
.build();
let formatted = format_message_for_test(&msg);
// Для простоты проверяем что текст присутствует
// В реальности здесь должна быть конвертация entities в markdown
assert!(formatted.contains("Bold text"));
}
// Helper функция для форматирования (упрощённая версия)
// В реальном коде это делается в src/input/main_input.rs::format_message_for_clipboard
fn format_message_for_test(msg: &tele_tui::tdlib::MessageInfo) -> String {
let mut result = String::new();
// Добавляем forward контекст если есть
if let Some(forward) = &msg.forward_from() {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
}
// Добавляем reply контекст если есть
if let Some(reply) = &msg.reply_to() {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
}
// Добавляем основной текст
result.push_str(msg.text());
result
}
#[cfg(all(test, feature = "clipboard"))]
mod clipboard_tests {
/// Test: Проверка что clipboard функции не падают
/// Примечание: Реальное тестирование clipboard требует GUI окружения
/// и может быть ненадёжным в CI. Этот тест просто проверяет что
/// arboard::Clipboard инициализируется без ошибок.
#[test]
#[ignore] // Игнорируем в CI, так как может не быть GUI окружения
fn test_clipboard_initialization() {
use arboard::Clipboard;
// Проверяем что можем создать clipboard
let result = Clipboard::new();
// В headless окружении может вернуть ошибку - это нормально
// Главное что не паникует
match result {
Ok(_) => {
// Clipboard доступен - отлично!
}
Err(_) => {
// Clipboard недоступен - ожидаемо в headless окружении
// Тест всё равно проходит
}
}
}
/// Test: Копирование в реальный clipboard (только для локального тестирования)
#[test]
#[ignore] // Игнорируем по умолчанию, запускать вручную: cargo test --ignored
fn test_copy_to_real_clipboard() {
use arboard::Clipboard;
let test_text = "Test message for clipboard";
// Пытаемся скопировать
if let Ok(mut clipboard) = Clipboard::new() {
let copy_result = clipboard.set_text(test_text);
assert!(copy_result.is_ok(), "Failed to copy to clipboard");
// Пытаемся прочитать обратно
if let Ok(content) = clipboard.get_text() {
assert_eq!(content, test_text, "Clipboard content mismatch");
}
}
}
/// Test: Кроссплатформенность clipboard
#[test]
fn test_clipboard_availability() {
use arboard::Clipboard;
// Этот тест просто проверяет что arboard доступен на всех платформах
// arboard поддерживает: Linux (X11/Wayland), Windows, macOS
let _clipboard_available = Clipboard::new().is_ok();
// Тест всегда проходит - мы просто проверяем что код компилируется
// и не паникует на разных платформах
}
}

Some files were not shown because too many files have changed in this diff Show More