Split core and TUI crates
This commit is contained in:
29
crates/tele-core/Cargo.toml
Normal file
29
crates/tele-core/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "tele-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
description = "Reusable Telegram/TDLib core for tele-tui"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/your-username/tele-tui"
|
||||
keywords = ["telegram", "tdlib"]
|
||||
categories = ["api-bindings"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
images = []
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
chrono = "0.4"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
base64 = "0.22.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
5
crates/tele-core/src/accounts/mod.rs
Normal file
5
crates/tele-core/src/accounts/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! Account profile data structures and validation.
|
||||
|
||||
pub mod profile;
|
||||
|
||||
pub use profile::{validate_account_name, AccountProfile, AccountsConfig};
|
||||
114
crates/tele-core/src/accounts/profile.rs
Normal file
114
crates/tele-core/src/accounts/profile.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! 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};
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
6
crates/tele-core/src/constants.rs
Normal file
6
crates/tele-core/src/constants.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
||||
pub const MAX_CHATS: usize = 200;
|
||||
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||
11
crates/tele-core/src/lib.rs
Normal file
11
crates/tele-core/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Reusable Telegram/TDLib core for tele-tui and future clients.
|
||||
|
||||
mod constants;
|
||||
mod utils;
|
||||
|
||||
pub mod accounts;
|
||||
pub mod message_grouping;
|
||||
pub mod tdlib;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
pub mod types;
|
||||
447
crates/tele-core/src/message_grouping.rs
Normal file
447
crates/tele-core/src/message_grouping.rs
Normal file
@@ -0,0 +1,447 @@
|
||||
//! Модуль для группировки сообщений по дате и отправителю
|
||||
//!
|
||||
//! Предоставляет функции для логической группировки сообщений
|
||||
//! перед отображением, отделяя логику группировки от рендеринга.
|
||||
|
||||
use crate::tdlib::MessageInfo;
|
||||
use crate::utils::get_day;
|
||||
|
||||
/// Элемент группированного списка сообщений
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MessageGroup<'a> {
|
||||
/// Разделитель даты (день в формате timestamp)
|
||||
DateSeparator(i32),
|
||||
/// Заголовок отправителя (is_outgoing, sender_name)
|
||||
SenderHeader {
|
||||
is_outgoing: bool,
|
||||
sender_name: String,
|
||||
},
|
||||
/// Сообщение
|
||||
Message(&'a MessageInfo),
|
||||
/// Альбом (группа фото с одинаковым media_album_id)
|
||||
Album(Vec<&'a MessageInfo>),
|
||||
}
|
||||
|
||||
/// Группирует сообщения по дате и отправителю
|
||||
///
|
||||
/// # Аргументы
|
||||
///
|
||||
/// * `messages` - Список сообщений для группировки
|
||||
///
|
||||
/// # Возвращает
|
||||
///
|
||||
/// Вектор `MessageGroup` с разделителями дат, заголовками отправителей и сообщениями
|
||||
///
|
||||
/// # Примеры
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tele_core::message_grouping::{group_messages, MessageGroup};
|
||||
///
|
||||
/// # use tele_core::tdlib::types::MessageBuilder;
|
||||
/// # use tele_core::types::MessageId;
|
||||
/// # let msg = MessageBuilder::new(MessageId::new(1)).sender_name("Alice").text("Hello").build();
|
||||
/// let messages = vec![msg];
|
||||
/// let grouped = group_messages(&messages);
|
||||
///
|
||||
/// for group in grouped {
|
||||
/// match group {
|
||||
/// MessageGroup::DateSeparator(_day) => {
|
||||
/// // Рендерим разделитель даты
|
||||
/// }
|
||||
/// MessageGroup::SenderHeader { is_outgoing, sender_name } => {
|
||||
/// // Рендерим заголовок отправителя
|
||||
/// println!("{}: {}", if is_outgoing { "Outgoing" } else { "Incoming" }, sender_name);
|
||||
/// }
|
||||
/// MessageGroup::Message(msg) => {
|
||||
/// // Рендерим сообщение
|
||||
/// println!("{}", msg.text());
|
||||
/// }
|
||||
/// MessageGroup::Album(messages) => {
|
||||
/// // Рендерим альбом (группу фото)
|
||||
/// println!("Album with {} photos", messages.len());
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn group_messages<'a>(messages: &'a [MessageInfo]) -> Vec<MessageGroup<'a>> {
|
||||
let mut result = Vec::new();
|
||||
let mut last_day: Option<i64> = None;
|
||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
||||
let mut album_acc: Vec<&MessageInfo> = Vec::new();
|
||||
|
||||
/// Сбрасывает аккумулятор альбома в результат
|
||||
fn flush_album<'a>(acc: &mut Vec<&'a MessageInfo>, result: &mut Vec<MessageGroup<'a>>) {
|
||||
if acc.is_empty() {
|
||||
return;
|
||||
}
|
||||
if acc.len() >= 2 {
|
||||
result.push(MessageGroup::Album(std::mem::take(acc)));
|
||||
} else {
|
||||
// Одно сообщение — не альбом
|
||||
result.push(MessageGroup::Message(acc.remove(0)));
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages {
|
||||
// Проверяем, нужно ли добавить разделитель даты
|
||||
let msg_day = get_day(msg.date());
|
||||
|
||||
if last_day != Some(msg_day) {
|
||||
// Flush аккумулятор перед разделителем даты
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
// Добавляем разделитель даты
|
||||
result.push(MessageGroup::DateSeparator(msg.date()));
|
||||
last_day = Some(msg_day);
|
||||
last_sender = None; // Сбрасываем отправителя при смене дня
|
||||
}
|
||||
|
||||
let sender_name = if msg.is_outgoing() {
|
||||
"Вы".to_string()
|
||||
} else {
|
||||
msg.sender_name().to_string()
|
||||
};
|
||||
|
||||
let current_sender = (msg.is_outgoing(), sender_name.clone());
|
||||
|
||||
// Проверяем, нужно ли показать заголовок отправителя
|
||||
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
||||
|
||||
if show_sender_header {
|
||||
// Flush аккумулятор перед сменой отправителя
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
|
||||
last_sender = Some(current_sender);
|
||||
}
|
||||
|
||||
// Проверяем, является ли сообщение частью альбома
|
||||
let album_id = msg.media_album_id();
|
||||
if album_id != 0 {
|
||||
// Проверяем, совпадает ли album_id с текущим аккумулятором
|
||||
if let Some(first) = album_acc.first() {
|
||||
if first.media_album_id() == album_id {
|
||||
// Тот же альбом — добавляем
|
||||
album_acc.push(msg);
|
||||
continue;
|
||||
} else {
|
||||
// Другой альбом — flush старый, начинаем новый
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
album_acc.push(msg);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Аккумулятор пуст — начинаем новый альбом
|
||||
album_acc.push(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Обычное сообщение (не альбом) — flush аккумулятор
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
result.push(MessageGroup::Message(msg));
|
||||
}
|
||||
|
||||
// Flush оставшийся аккумулятор
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tdlib::types::MessageBuilder;
|
||||
use crate::types::MessageId;
|
||||
|
||||
#[test]
|
||||
fn test_group_messages_by_date() {
|
||||
// Создаём сообщения с разными датами
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Message 1")
|
||||
.date(1609459200) // 2021-01-01 00:00:00 UTC
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Message 2")
|
||||
.date(1609545600) // 2021-01-02 00:00:00 UTC
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// Должно быть: DateSep, SenderHeader, Message, DateSep, SenderHeader, Message
|
||||
assert_eq!(grouped.len(), 6);
|
||||
|
||||
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
assert!(matches!(grouped[3], MessageGroup::DateSeparator(_)));
|
||||
assert!(matches!(grouped[4], MessageGroup::SenderHeader { .. }));
|
||||
assert!(matches!(grouped[5], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_messages_by_sender() {
|
||||
// Создаём сообщения от разных отправителей
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Message 1")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Message 2")
|
||||
.date(1609459300) // +100 секунд, тот же день
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||
.sender_name("Bob")
|
||||
.text("Message 3")
|
||||
.date(1609459400)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2, msg3];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// Должно быть: DateSep, SenderHeader(Alice), Message, Message, SenderHeader(Bob), Message
|
||||
assert_eq!(grouped.len(), 6);
|
||||
|
||||
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||
|
||||
if let MessageGroup::SenderHeader { sender_name, .. } = &grouped[1] {
|
||||
assert_eq!(sender_name, "Alice");
|
||||
} else {
|
||||
panic!("Expected SenderHeader");
|
||||
}
|
||||
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
assert!(matches!(grouped[3], MessageGroup::Message(_)));
|
||||
|
||||
if let MessageGroup::SenderHeader { sender_name, .. } = &grouped[4] {
|
||||
assert_eq!(sender_name, "Bob");
|
||||
} else {
|
||||
panic!("Expected SenderHeader");
|
||||
}
|
||||
|
||||
assert!(matches!(grouped[5], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_outgoing_vs_incoming() {
|
||||
// Проверяем группировку исходящих и входящих сообщений
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Hello")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Me")
|
||||
.text("Hi")
|
||||
.date(1609459300)
|
||||
.outgoing()
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// Должно быть: DateSep, SenderHeader(Alice), Message, SenderHeader(Me), Message
|
||||
assert_eq!(grouped.len(), 5);
|
||||
|
||||
if let MessageGroup::SenderHeader { is_outgoing, sender_name } = &grouped[1] {
|
||||
assert!(!*is_outgoing);
|
||||
assert_eq!(sender_name, "Alice");
|
||||
} else {
|
||||
panic!("Expected SenderHeader");
|
||||
}
|
||||
|
||||
if let MessageGroup::SenderHeader { is_outgoing, sender_name } = &grouped[3] {
|
||||
assert!(*is_outgoing);
|
||||
assert_eq!(sender_name, "Вы");
|
||||
} else {
|
||||
panic!("Expected SenderHeader");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_messages() {
|
||||
let messages: Vec<MessageInfo> = vec![];
|
||||
let grouped = group_messages(&messages);
|
||||
assert_eq!(grouped.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_message() {
|
||||
let msg = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Single message")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let messages = vec![msg];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// Должно быть: DateSep, SenderHeader, Message
|
||||
assert_eq!(grouped.len(), 3);
|
||||
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_grouping_two_photos() {
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 1")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.media_album_id(12345)
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 2")
|
||||
.date(1609459201)
|
||||
.incoming()
|
||||
.media_album_id(12345)
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Album
|
||||
assert_eq!(grouped.len(), 3);
|
||||
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||
if let MessageGroup::Album(album) = &grouped[2] {
|
||||
assert_eq!(album.len(), 2);
|
||||
assert_eq!(album[0].id(), MessageId::new(1));
|
||||
assert_eq!(album[1].id(), MessageId::new(2));
|
||||
} else {
|
||||
panic!("Expected Album, got {:?}", grouped[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_single_photo_not_album() {
|
||||
// Одно сообщение с album_id → не альбом, обычное сообщение
|
||||
let msg = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Single photo")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.media_album_id(12345)
|
||||
.build();
|
||||
|
||||
let messages = vec![msg];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Message (не Album)
|
||||
assert_eq!(grouped.len(), 3);
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_album_with_regular_messages() {
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Text message")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 1")
|
||||
.date(1609459201)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||
.sender_name("Alice")
|
||||
.text("Photo 2")
|
||||
.date(1609459202)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||
.sender_name("Alice")
|
||||
.text("After album")
|
||||
.date(1609459203)
|
||||
.incoming()
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2, msg3, msg4];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Message, Album, Message
|
||||
assert_eq!(grouped.len(), 5);
|
||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||
assert!(matches!(grouped[3], MessageGroup::Album(_)));
|
||||
assert!(matches!(grouped[4], MessageGroup::Message(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_different_albums() {
|
||||
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||
.sender_name("Alice")
|
||||
.text("Album 1 - Photo 1")
|
||||
.date(1609459200)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||
.sender_name("Alice")
|
||||
.text("Album 1 - Photo 2")
|
||||
.date(1609459201)
|
||||
.incoming()
|
||||
.media_album_id(100)
|
||||
.build();
|
||||
|
||||
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||
.sender_name("Alice")
|
||||
.text("Album 2 - Photo 1")
|
||||
.date(1609459202)
|
||||
.incoming()
|
||||
.media_album_id(200)
|
||||
.build();
|
||||
|
||||
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||
.sender_name("Alice")
|
||||
.text("Album 2 - Photo 2")
|
||||
.date(1609459203)
|
||||
.incoming()
|
||||
.media_album_id(200)
|
||||
.build();
|
||||
|
||||
let messages = vec![msg1, msg2, msg3, msg4];
|
||||
let grouped = group_messages(&messages);
|
||||
|
||||
// DateSep, SenderHeader, Album(2), Album(2)
|
||||
assert_eq!(grouped.len(), 4);
|
||||
if let MessageGroup::Album(a1) = &grouped[2] {
|
||||
assert_eq!(a1.len(), 2);
|
||||
assert_eq!(a1[0].media_album_id(), 100);
|
||||
} else {
|
||||
panic!("Expected first Album");
|
||||
}
|
||||
if let MessageGroup::Album(a2) = &grouped[3] {
|
||||
assert_eq!(a2.len(), 2);
|
||||
assert_eq!(a2[0].media_album_id(), 200);
|
||||
} else {
|
||||
panic!("Expected second Album");
|
||||
}
|
||||
}
|
||||
}
|
||||
215
crates/tele-core/src/tdlib/auth.rs
Normal file
215
crates/tele-core/src/tdlib/auth.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use tdlib_rs::enums::{AuthorizationState, Update};
|
||||
use tdlib_rs::functions;
|
||||
|
||||
/// Состояние процесса авторизации в Telegram.
|
||||
///
|
||||
/// Отслеживает текущий этап аутентификации пользователя,
|
||||
/// от инициализации TDLib до полной авторизации.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthState {
|
||||
/// Ожидание параметров TDLib (начальное состояние).
|
||||
WaitTdlibParameters,
|
||||
|
||||
/// Ожидание ввода номера телефона.
|
||||
WaitPhoneNumber,
|
||||
|
||||
/// Ожидание ввода кода подтверждения из SMS/Telegram.
|
||||
WaitCode,
|
||||
|
||||
/// Ожидание ввода пароля двухфакторной аутентификации (2FA).
|
||||
WaitPassword,
|
||||
|
||||
/// Авторизация завершена, клиент готов к работе.
|
||||
Ready,
|
||||
|
||||
/// Соединение закрыто.
|
||||
Closed,
|
||||
|
||||
/// Произошла ошибка авторизации.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Менеджер авторизации TDLib.
|
||||
///
|
||||
/// Управляет процессом авторизации пользователя в Telegram,
|
||||
/// отслеживает текущее состояние и предоставляет методы
|
||||
/// для отправки учетных данных (номер телефона, код, пароль).
|
||||
///
|
||||
/// # Процесс авторизации
|
||||
///
|
||||
/// 1. `WaitTdlibParameters` → автоматически
|
||||
/// 2. `WaitPhoneNumber` → [`send_phone_number()`](Self::send_phone_number)
|
||||
/// 3. `WaitCode` → [`send_code()`](Self::send_code)
|
||||
/// 4. `WaitPassword` (опционально) → [`send_password()`](Self::send_password)
|
||||
/// 5. `Ready` → авторизация завершена
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut auth_manager = AuthManager::new(client_id);
|
||||
///
|
||||
/// // Отправляем номер телефона
|
||||
/// auth_manager.send_phone_number("+1234567890".to_string()).await?;
|
||||
///
|
||||
/// // После получения кода из SMS
|
||||
/// auth_manager.send_code("12345".to_string()).await?;
|
||||
///
|
||||
/// // Если включена 2FA
|
||||
/// if auth_manager.state == AuthState::WaitPassword {
|
||||
/// auth_manager.send_password("my_password".to_string()).await?;
|
||||
/// }
|
||||
///
|
||||
/// // Проверяем авторизацию
|
||||
/// if auth_manager.is_authenticated() {
|
||||
/// println!("Successfully authenticated!");
|
||||
/// }
|
||||
/// ```
|
||||
pub struct AuthManager {
|
||||
/// Текущее состояние авторизации.
|
||||
pub state: AuthState,
|
||||
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AuthManager {
|
||||
/// Создает новый менеджер авторизации.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self { state: AuthState::WaitTdlibParameters, client_id }
|
||||
}
|
||||
|
||||
/// Проверяет, завершена ли авторизация.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` если состояние равно `AuthState::Ready`, иначе `false`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// if auth_manager.is_authenticated() {
|
||||
/// println!("User is authenticated");
|
||||
/// }
|
||||
/// ```
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.state == AuthState::Ready
|
||||
}
|
||||
|
||||
/// Обрабатывает обновление состояния авторизации от TDLib.
|
||||
///
|
||||
/// Автоматически обновляет внутреннее состояние [`AuthState`] на основе
|
||||
/// полученного update от TDLib.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `update` - Обновление от TDLib (проверяется на `Update::AuthorizationState`)
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Этот метод должен вызываться для каждого update от TDLib,
|
||||
/// чтобы состояние авторизации оставалось актуальным.
|
||||
pub fn handle_auth_update(&mut self, update: &Update) {
|
||||
if let Update::AuthorizationState(auth_update) = update {
|
||||
self.state = match &auth_update.authorization_state {
|
||||
AuthorizationState::WaitTdlibParameters => AuthState::WaitTdlibParameters,
|
||||
AuthorizationState::WaitPhoneNumber => AuthState::WaitPhoneNumber,
|
||||
AuthorizationState::WaitCode(_) => AuthState::WaitCode,
|
||||
AuthorizationState::WaitPassword(_) => AuthState::WaitPassword,
|
||||
AuthorizationState::Ready => AuthState::Ready,
|
||||
AuthorizationState::Closed => AuthState::Closed,
|
||||
_ => return,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправляет номер телефона для авторизации.
|
||||
///
|
||||
/// Используется на этапе [`AuthState::WaitPhoneNumber`].
|
||||
/// После успешной отправки состояние изменится на `WaitCode`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `phone` - Номер телефона в международном формате (например, "+1234567890")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Номер телефона принят, ожидайте SMS с кодом
|
||||
/// * `Err(String)` - Ошибка (неверный формат, проблемы с сетью и т.д.)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// auth_manager.send_phone_number("+1234567890".to_string()).await?;
|
||||
/// ```
|
||||
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||
functions::set_authentication_phone_number(phone, None, self.client_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Ошибка отправки номера: {:?}", e))
|
||||
}
|
||||
|
||||
/// Отправляет код подтверждения из SMS или Telegram.
|
||||
///
|
||||
/// Используется на этапе [`AuthState::WaitCode`].
|
||||
/// После успешной проверки состояние изменится на `Ready` или `WaitPassword`
|
||||
/// (если включена двухфакторная аутентификация).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `code` - Код подтверждения (обычно 5 цифр)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Код верный
|
||||
/// * `Err(String)` - Неверный код или истек срок действия
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// auth_manager.send_code("12345".to_string()).await?;
|
||||
/// ```
|
||||
pub async fn send_code(&self, code: String) -> Result<(), String> {
|
||||
functions::check_authentication_code(code, self.client_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Ошибка проверки кода: {:?}", e))
|
||||
}
|
||||
|
||||
/// Отправляет пароль двухфакторной аутентификации (2FA).
|
||||
///
|
||||
/// Используется на этапе [`AuthState::WaitPassword`] (только если 2FA включена).
|
||||
/// После успешной проверки состояние изменится на `Ready`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `password` - Пароль двухфакторной аутентификации
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Пароль верный, авторизация завершена
|
||||
/// * `Err(String)` - Неверный пароль
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// if auth_manager.state == AuthState::WaitPassword {
|
||||
/// auth_manager.send_password("my_2fa_password".to_string()).await?;
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn send_password(&self, password: String) -> Result<(), String> {
|
||||
functions::check_authentication_password(password, self.client_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Ошибка проверки пароля: {:?}", e))
|
||||
}
|
||||
}
|
||||
136
crates/tele-core/src/tdlib/chat_helpers.rs
Normal file
136
crates/tele-core/src/tdlib/chat_helpers.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Chat management helper functions.
|
||||
//!
|
||||
//! This module contains utility functions for managing chats,
|
||||
//! including finding, updating, and adding/removing chats.
|
||||
|
||||
use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::types::ChatInfo;
|
||||
|
||||
/// Обновляет поле чата, если чат найден.
|
||||
pub fn update_chat<F>(client: &mut TdClient, chat_id: ChatId, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut ChatInfo),
|
||||
{
|
||||
client.update_chat(chat_id, updater);
|
||||
}
|
||||
|
||||
/// Добавляет новый чат или обновляет существующий
|
||||
pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
||||
// Pattern match to get inner Chat struct
|
||||
let TdChat::Chat(td_chat) = td_chat_enum;
|
||||
|
||||
// Пропускаем удалённые аккаунты
|
||||
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
|
||||
// Удаляем из списка если уже был добавлен
|
||||
client.remove_chat(ChatId::new(td_chat.id));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ищем позицию в Main списке (если есть)
|
||||
let main_position = td_chat
|
||||
.positions
|
||||
.iter()
|
||||
.find(|pos| matches!(pos.list, ChatList::Main));
|
||||
|
||||
// Получаем order и is_pinned из позиции, или используем значения по умолчанию
|
||||
let (order, is_pinned) = main_position
|
||||
.map(|p| (p.order, p.is_pinned))
|
||||
.unwrap_or((1, false)); // order=1 чтобы чат отображался
|
||||
|
||||
let (last_message, last_message_date) = td_chat
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|m| (TdClient::extract_message_text_static(m).0, m.date))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Извлекаем user_id для приватных чатов и сохраняем связь
|
||||
let username = match &td_chat.r#type {
|
||||
ChatType::Private(private) => {
|
||||
// Ограничиваем размер chat_user_ids
|
||||
let chat_id = ChatId::new(td_chat.id);
|
||||
let user_id = UserId::new(private.user_id);
|
||||
client.update_user_cache(|cache| {
|
||||
if cache.chat_user_ids.len() >= MAX_CHAT_USER_IDS
|
||||
&& !cache.chat_user_ids.contains_key(&chat_id)
|
||||
{
|
||||
// Удаляем случайную запись (первую найденную)
|
||||
if let Some(&key) = cache.chat_user_ids.keys().next() {
|
||||
cache.chat_user_ids.remove(&key);
|
||||
}
|
||||
}
|
||||
cache.chat_user_ids.insert(chat_id, user_id);
|
||||
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
||||
cache
|
||||
.user_usernames
|
||||
.peek(&user_id)
|
||||
.map(|u| format!("@{}", u))
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Извлекаем ID папок из позиций
|
||||
let folder_ids: Vec<i32> = td_chat
|
||||
.positions
|
||||
.iter()
|
||||
.filter_map(|pos| match &pos.list {
|
||||
ChatList::Folder(folder) => Some(folder.chat_folder_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Проверяем mute статус
|
||||
let is_muted = td_chat.notification_settings.mute_for > 0;
|
||||
|
||||
let chat_info = ChatInfo {
|
||||
id: ChatId::new(td_chat.id),
|
||||
title: td_chat.title.clone(),
|
||||
username,
|
||||
last_message,
|
||||
last_message_date,
|
||||
unread_count: td_chat.unread_count,
|
||||
unread_mention_count: td_chat.unread_mention_count,
|
||||
is_pinned,
|
||||
order,
|
||||
last_read_outbox_message_id: MessageId::new(td_chat.last_read_outbox_message_id),
|
||||
folder_ids,
|
||||
is_muted,
|
||||
draft_text: None,
|
||||
};
|
||||
|
||||
let chat_info_for_update = chat_info.clone();
|
||||
let updated_existing = client.update_chat(ChatId::new(td_chat.id), |existing| {
|
||||
existing.title = chat_info_for_update.title;
|
||||
existing.last_message = chat_info_for_update.last_message;
|
||||
existing.last_message_date = chat_info_for_update.last_message_date;
|
||||
existing.unread_count = chat_info_for_update.unread_count;
|
||||
existing.unread_mention_count = chat_info_for_update.unread_mention_count;
|
||||
existing.last_read_outbox_message_id = chat_info_for_update.last_read_outbox_message_id;
|
||||
existing.folder_ids = chat_info_for_update.folder_ids;
|
||||
existing.is_muted = chat_info_for_update.is_muted;
|
||||
|
||||
// Обновляем username если он появился
|
||||
if let Some(username) = chat_info_for_update.username {
|
||||
existing.username = Some(username);
|
||||
}
|
||||
|
||||
// Обновляем позицию только если она пришла
|
||||
if main_position.is_some() {
|
||||
existing.is_pinned = chat_info_for_update.is_pinned;
|
||||
existing.order = chat_info_for_update.order;
|
||||
}
|
||||
});
|
||||
|
||||
if !updated_existing {
|
||||
client.push_chat(chat_info);
|
||||
// Ограничиваем количество чатов
|
||||
client.trim_chats_to_max_by_order(MAX_CHATS);
|
||||
}
|
||||
|
||||
// Сортируем чаты по order (TDLib order учитывает pinned и время)
|
||||
client.sort_chats_by_order();
|
||||
}
|
||||
380
crates/tele-core/src/tdlib/chats.rs
Normal file
380
crates/tele-core/src/tdlib/chats.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use crate::types::{ChatId, UserId};
|
||||
use std::time::Instant;
|
||||
use tdlib_rs::enums::{ChatAction, ChatList, ChatType};
|
||||
use tdlib_rs::functions;
|
||||
|
||||
use super::types::{ChatInfo, FolderInfo, ProfileInfo};
|
||||
|
||||
/// Менеджер чатов TDLib.
|
||||
///
|
||||
/// Управляет списком чатов, папками, информацией о профилях
|
||||
/// и typing-статусом собеседников.
|
||||
///
|
||||
/// # Основные возможности
|
||||
///
|
||||
/// - Загрузка чатов из главного списка и папок
|
||||
/// - Получение информации о профиле чата/пользователя
|
||||
/// - Отправка typing-индикатора ("печатает...")
|
||||
/// - Отслеживание typing-статуса собеседников
|
||||
/// - Выход из чатов/групп
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut chat_manager = ChatManager::new(client_id);
|
||||
///
|
||||
/// // Загружаем чаты
|
||||
/// chat_manager.load_chats(50).await?;
|
||||
///
|
||||
/// // Получаем информацию о профиле
|
||||
/// let profile = chat_manager.get_profile_info(chat_id).await?;
|
||||
/// println!("Bio: {}", profile.bio.unwrap_or_default());
|
||||
/// ```
|
||||
pub struct ChatManager {
|
||||
/// Список загруженных чатов.
|
||||
pub chats: Vec<ChatInfo>,
|
||||
|
||||
/// Список папок чатов.
|
||||
pub folders: Vec<FolderInfo>,
|
||||
|
||||
/// Позиция в главном списке чатов для пагинации.
|
||||
pub main_chat_list_position: i32,
|
||||
|
||||
/// Typing status для текущего чата: (user_id, action_text, timestamp).
|
||||
pub typing_status: Option<(UserId, String, Instant)>,
|
||||
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
impl ChatManager {
|
||||
/// Создает новый менеджер чатов.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Новый экземпляр `ChatManager` с пустым списком чатов.
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self {
|
||||
chats: Vec::new(),
|
||||
folders: Vec::new(),
|
||||
main_chat_list_position: 0,
|
||||
typing_status: None,
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает чаты из главного списка.
|
||||
///
|
||||
/// Запрашивает у TDLib чаты из основного списка (исключая архив).
|
||||
/// После вызова чаты будут доступны через updates от TDLib.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `limit` - Максимальное количество чатов для загрузки
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Запрос отправлен, чаты будут загружены через updates
|
||||
/// * `Err(String)` - Ошибка при отправке запроса
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// chat_manager.load_chats(50).await?;
|
||||
/// ```
|
||||
pub async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||
let result = functions::load_chats(Some(ChatList::Main), limit, self.client_id).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка загрузки чатов: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает чаты из указанной папки.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `folder_id` - ID папки чатов
|
||||
/// * `limit` - Максимальное количество чатов для загрузки
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Запрос отправлен
|
||||
/// * `Err(String)` - Ошибка при отправке запроса
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузить чаты из папки с ID 1
|
||||
/// chat_manager.load_folder_chats(1, 50).await?;
|
||||
/// ```
|
||||
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
|
||||
let chat_list =
|
||||
ChatList::Folder(tdlib_rs::types::ChatListFolder { chat_folder_id: folder_id });
|
||||
|
||||
let result = functions::load_chats(Some(chat_list), limit, self.client_id).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка загрузки папки: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Выходит из чата или группы.
|
||||
///
|
||||
/// Для приватных чатов — удаляет историю, для групп — покидает группу.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата для выхода
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Успешный выход
|
||||
/// * `Err(String)` - Ошибка (нет прав, чат не найден и т.д.)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// chat_manager.leave_chat(ChatId::new(123456)).await?;
|
||||
/// ```
|
||||
pub async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||
let result = functions::leave_chat(chat_id.as_i64(), self.client_id).await;
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка выхода из чата: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Получает детальную информацию о профиле чата или пользователя.
|
||||
///
|
||||
/// Загружает полную информацию включая bio, номер телефона, username,
|
||||
/// статус онлайн (для личных чатов), количество участников и описание
|
||||
/// (для групп/каналов).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата для получения информации
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(ProfileInfo)` - Информация о профиле
|
||||
/// * `Err(String)` - Ошибка получения данных
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let profile = chat_manager.get_profile_info(ChatId::new(123)).await?;
|
||||
/// println!("Title: {}", profile.title);
|
||||
/// println!("Bio: {}", profile.bio.unwrap_or_default());
|
||||
/// println!("Members: {}", profile.member_count.unwrap_or(0));
|
||||
/// ```
|
||||
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||
// Получаем основную информацию о чате
|
||||
let chat_result = functions::get_chat(chat_id.as_i64(), self.client_id).await;
|
||||
let chat_enum = match chat_result {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(format!("Ошибка получения чата: {:?}", e)),
|
||||
};
|
||||
|
||||
let tdlib_rs::enums::Chat::Chat(chat) = chat_enum;
|
||||
|
||||
let chat_type_str = match &chat.r#type {
|
||||
ChatType::Private(_) => "Личный чат",
|
||||
ChatType::Supergroup(sg) => {
|
||||
if sg.is_channel {
|
||||
"Канал"
|
||||
} else {
|
||||
"Группа"
|
||||
}
|
||||
}
|
||||
ChatType::BasicGroup(_) => "Группа",
|
||||
ChatType::Secret(_) => "Секретный чат",
|
||||
};
|
||||
|
||||
let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
|
||||
|
||||
// Для личных чатов получаем информацию о пользователе
|
||||
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
|
||||
&chat.r#type
|
||||
{
|
||||
match functions::get_user(private_chat.user_id, self.client_id).await {
|
||||
Ok(tdlib_rs::enums::User::User(user)) => {
|
||||
let bio_opt =
|
||||
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
|
||||
functions::get_user_full_info(private_chat.user_id, self.client_id)
|
||||
.await
|
||||
{
|
||||
full_info.bio.map(|b| b.text)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let online_status_str = match user.status {
|
||||
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
|
||||
tdlib_rs::enums::UserStatus::Recently(_) => {
|
||||
Some("Был(а) недавно".to_string())
|
||||
}
|
||||
tdlib_rs::enums::UserStatus::LastWeek(_) => {
|
||||
Some("Был(а) на этой неделе".to_string())
|
||||
}
|
||||
tdlib_rs::enums::UserStatus::LastMonth(_) => {
|
||||
Some("Был(а) в этом месяце".to_string())
|
||||
}
|
||||
tdlib_rs::enums::UserStatus::Offline(s) => {
|
||||
// Форматируем время последнего визита
|
||||
Some(format!("Был(а) в сети {}", s.was_online))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
|
||||
|
||||
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
|
||||
}
|
||||
_ => (None, None, None, None),
|
||||
}
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
};
|
||||
|
||||
// Для групп/каналов получаем полную информацию
|
||||
let (member_count, description, invite_link) = if is_group {
|
||||
if let ChatType::Supergroup(sg) = &chat.r#type {
|
||||
match functions::get_supergroup_full_info(sg.supergroup_id, self.client_id).await {
|
||||
Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) => {
|
||||
let desc = if !full_info.description.is_empty() {
|
||||
Some(full_info.description.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let link = full_info
|
||||
.invite_link
|
||||
.as_ref()
|
||||
.map(|l| l.invite_link.clone());
|
||||
(Some(full_info.member_count), desc, link)
|
||||
}
|
||||
_ => (None, None, None),
|
||||
}
|
||||
} else if let ChatType::BasicGroup(bg) = &chat.r#type {
|
||||
match functions::get_basic_group_full_info(bg.basic_group_id, self.client_id).await
|
||||
{
|
||||
Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) => {
|
||||
let desc = if !full_info.description.is_empty() {
|
||||
Some(full_info.description.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let link = full_info.invite_link.map(|l| l.invite_link);
|
||||
(Some(full_info.members.len() as i32), desc, link)
|
||||
}
|
||||
Err(_) => (None, None, None),
|
||||
}
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
Ok(ProfileInfo {
|
||||
chat_id,
|
||||
title: chat.title,
|
||||
username,
|
||||
bio,
|
||||
phone_number,
|
||||
chat_type: chat_type_str.to_string(),
|
||||
member_count,
|
||||
description,
|
||||
invite_link,
|
||||
is_group,
|
||||
online_status,
|
||||
})
|
||||
}
|
||||
|
||||
/// Отправляет typing-действие в чат.
|
||||
///
|
||||
/// Показывает собеседнику индикатор "печатает..." или другой статус активности.
|
||||
/// Действие автоматически сбрасывается через 5 секунд.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `action` - Тип действия (Typing, RecordingVideo, UploadingPhoto и т.д.)
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Этот метод нужно вызывать периодически (каждые 5 секунд) пока действие активно.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use tdlib_rs::enums::ChatAction;
|
||||
///
|
||||
/// // Показать индикатор "печатает..."
|
||||
/// chat_manager.send_chat_action(
|
||||
/// chat_id,
|
||||
/// ChatAction::Typing
|
||||
/// ).await;
|
||||
/// ```
|
||||
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||
let _ =
|
||||
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
|
||||
}
|
||||
|
||||
/// Очищает устаревший typing-статус.
|
||||
///
|
||||
/// Удаляет typing-статус если прошло более 5 секунд с момента последнего обновления.
|
||||
/// Вызывайте этот метод периодически (например, каждый тик UI) для своевременной
|
||||
/// очистки индикатора "печатает...".
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `true` - Если статус был очищен
|
||||
/// * `false` - Если статус актуален или его не было
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // В основном цикле UI
|
||||
/// if chat_manager.clear_stale_typing_status() {
|
||||
/// // Перерисовать UI чтобы убрать индикатор "печатает..."
|
||||
/// needs_redraw = true;
|
||||
/// }
|
||||
/// ```
|
||||
pub fn clear_stale_typing_status(&mut self) -> bool {
|
||||
if let Some((_, _, timestamp)) = self.typing_status {
|
||||
if timestamp.elapsed().as_secs() > 5 {
|
||||
self.typing_status = None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Получает текст typing-индикатора для отображения.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Some(String)` - Текст действия (например, "печатает...", "записывает видео...")
|
||||
/// * `None` - Нет активного typing-статуса
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// if let Some(typing_text) = chat_manager.get_typing_text() {
|
||||
/// println!("Status: {}", typing_text);
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(dead_code)]
|
||||
pub fn get_typing_text(&self) -> Option<String> {
|
||||
self.typing_status
|
||||
.as_ref()
|
||||
.map(|(_, action, _)| action.clone())
|
||||
}
|
||||
}
|
||||
848
crates/tele-core/src/tdlib/client.rs
Normal file
848
crates/tele-core/src/tdlib/client.rs
Normal file
@@ -0,0 +1,848 @@
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use super::auth::{AuthManager, AuthState};
|
||||
use super::chats::ChatManager;
|
||||
use super::messages::MessageManager;
|
||||
use super::reactions::ReactionManager;
|
||||
use super::types::{
|
||||
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
|
||||
};
|
||||
use super::users::UserCache;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TdCredentials {
|
||||
pub api_id: i32,
|
||||
pub api_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TdClientConfig {
|
||||
pub credentials: TdCredentials,
|
||||
pub db_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingMessageEvent {
|
||||
pub chat: ChatInfo,
|
||||
pub message: MessageInfo,
|
||||
pub sender_name: String,
|
||||
}
|
||||
|
||||
/// TDLib client wrapper for Telegram integration.
|
||||
///
|
||||
/// Provides high-level API for authentication, chat management, messaging,
|
||||
/// and user caching. Delegates functionality to specialized managers:
|
||||
/// - `AuthManager` for authentication flow
|
||||
/// - `ChatManager` for chat operations
|
||||
/// - `MessageManager` for message operations
|
||||
/// - `UserCache` for user information caching
|
||||
/// - `ReactionManager` for message reactions
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use tele_core::tdlib::TdClient;
|
||||
///
|
||||
/// let mut client = TdClient::new(tele_core::tdlib::TdClientConfig {
|
||||
/// credentials: tele_core::tdlib::TdCredentials {
|
||||
/// api_id: 123,
|
||||
/// api_hash: "hash".to_string(),
|
||||
/// },
|
||||
/// db_path: std::path::PathBuf::from("tdlib_data"),
|
||||
/// });
|
||||
///
|
||||
/// // Start authorization
|
||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||
/// client.send_code("12345".to_string()).await?;
|
||||
///
|
||||
/// // Load chats
|
||||
/// client.load_chats(50).await?;
|
||||
/// # Ok::<(), String>(())
|
||||
/// ```
|
||||
pub struct TdClient {
|
||||
pub api_id: i32,
|
||||
pub api_hash: String,
|
||||
pub db_path: PathBuf,
|
||||
client_id: i32,
|
||||
|
||||
// Менеджеры (делегируем им функциональность)
|
||||
pub auth: AuthManager,
|
||||
pub chat_manager: ChatManager,
|
||||
pub message_manager: MessageManager,
|
||||
pub user_cache: UserCache,
|
||||
pub reaction_manager: ReactionManager,
|
||||
incoming_message_events: VecDeque<IncomingMessageEvent>,
|
||||
|
||||
// Состояние сети
|
||||
pub network_state: NetworkState,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TdClient {
|
||||
/// Creates a new TDLib client instance.
|
||||
///
|
||||
/// Initializes all managers and sets initial network state to Connecting.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `TdClient` instance ready for authentication.
|
||||
pub fn new(config: TdClientConfig) -> Self {
|
||||
let client_id = tdlib_rs::create_client();
|
||||
|
||||
Self {
|
||||
api_id: config.credentials.api_id,
|
||||
api_hash: config.credentials.api_hash,
|
||||
db_path: config.db_path,
|
||||
client_id,
|
||||
auth: AuthManager::new(client_id),
|
||||
chat_manager: ChatManager::new(client_id),
|
||||
message_manager: MessageManager::new(client_id),
|
||||
user_cache: UserCache::new(client_id),
|
||||
reaction_manager: ReactionManager::new(client_id),
|
||||
incoming_message_events: VecDeque::new(),
|
||||
network_state: NetworkState::Connecting,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enqueue_incoming_message_event(
|
||||
&mut self,
|
||||
chat: ChatInfo,
|
||||
message: MessageInfo,
|
||||
sender_name: String,
|
||||
) {
|
||||
self.incoming_message_events
|
||||
.push_back(IncomingMessageEvent { chat, message, sender_name });
|
||||
}
|
||||
|
||||
pub fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent> {
|
||||
self.incoming_message_events.drain(..).collect()
|
||||
}
|
||||
|
||||
// Делегирование к auth
|
||||
|
||||
/// Sends phone number for authentication.
|
||||
///
|
||||
/// This is the first step of the authentication flow.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `phone` - Phone number in international format (e.g., "+1234567890")
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the phone number is invalid or network request fails.
|
||||
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||
self.auth.send_phone_number(phone).await
|
||||
}
|
||||
|
||||
/// Sends authentication code received via SMS.
|
||||
///
|
||||
/// This is the second step of the authentication flow.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `code` - Authentication code (typically 5 digits)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the code is invalid or expired.
|
||||
pub async fn send_code(&self, code: String) -> Result<(), String> {
|
||||
self.auth.send_code(code).await
|
||||
}
|
||||
|
||||
/// Sends 2FA password if required.
|
||||
///
|
||||
/// This is the third step of the authentication flow (if 2FA is enabled).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `password` - Two-factor authentication password
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the password is incorrect.
|
||||
pub async fn send_password(&self, password: String) -> Result<(), String> {
|
||||
self.auth.send_password(password).await
|
||||
}
|
||||
|
||||
// Делегирование к chat_manager
|
||||
|
||||
/// Loads chats from the main chat list.
|
||||
///
|
||||
/// Loads up to `limit` chats from ChatList::Main, excluding archived chats.
|
||||
/// Filters out "Deleted Account" chats automatically.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `limit` - Maximum number of chats to load (typically 50-200)
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the network request fails.
|
||||
pub async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||
self.chat_manager.load_chats(limit).await
|
||||
}
|
||||
|
||||
/// Loads chats from a specific folder.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `folder_id` - Folder ID (1-9 for user folders)
|
||||
/// * `limit` - Maximum number of chats to load
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the folder doesn't exist or network request fails.
|
||||
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
|
||||
self.chat_manager.load_folder_chats(folder_id, limit).await
|
||||
}
|
||||
|
||||
/// Leaves a group or channel.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID of the chat to leave
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the user is not a member or network request fails.
|
||||
pub async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||
self.chat_manager.leave_chat(chat_id).await
|
||||
}
|
||||
|
||||
/// Gets profile information for a chat.
|
||||
///
|
||||
/// Fetches detailed information including bio, username, member count, etc.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID of the chat
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `ProfileInfo` with chat details
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the chat doesn't exist or network request fails.
|
||||
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||
self.chat_manager.get_profile_info(chat_id).await
|
||||
}
|
||||
|
||||
pub async fn send_chat_action(&self, chat_id: ChatId, action: tdlib_rs::enums::ChatAction) {
|
||||
self.chat_manager.send_chat_action(chat_id, action).await
|
||||
}
|
||||
|
||||
pub fn clear_stale_typing_status(&mut self) -> bool {
|
||||
self.chat_manager.clear_stale_typing_status()
|
||||
}
|
||||
|
||||
fn last_read_outbox_message_id(&self, chat_id: ChatId) -> MessageId {
|
||||
self.chats()
|
||||
.iter()
|
||||
.find(|chat| chat.id == chat_id)
|
||||
.map(|chat| chat.last_read_outbox_message_id)
|
||||
.unwrap_or(MessageId::new(0))
|
||||
}
|
||||
|
||||
// Делегирование к message_manager
|
||||
pub async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.get_chat_history(chat_id, limit, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.load_older_messages(chat_id, from_message_id, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_pinned_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.get_pinned_messages(chat_id, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
||||
self.message_manager
|
||||
.load_current_pinned_message(chat_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.search_messages(chat_id, query, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<super::types::ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.send_message(
|
||||
chat_id,
|
||||
text,
|
||||
reply_to_message_id,
|
||||
reply_info,
|
||||
last_read_outbox_message_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.edit_message(chat_id, message_id, text, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
self.message_manager
|
||||
.delete_messages(chat_id, message_ids, revoke)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn forward_messages(
|
||||
&self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
self.message_manager
|
||||
.forward_messages(to_chat_id, from_chat_id, message_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
self.message_manager.set_draft_message(chat_id, text).await
|
||||
}
|
||||
|
||||
pub fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.message_manager.push_message(msg)
|
||||
}
|
||||
|
||||
pub async fn fetch_missing_reply_info(&mut self) {
|
||||
self.message_manager.fetch_missing_reply_info().await
|
||||
}
|
||||
|
||||
pub async fn process_pending_view_messages(&mut self) {
|
||||
self.message_manager.process_pending_view_messages().await
|
||||
}
|
||||
|
||||
// Делегирование к user_cache
|
||||
pub fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
self.user_cache.get_status_by_chat_id(chat_id)
|
||||
}
|
||||
|
||||
pub async fn process_pending_user_ids(&mut self) {
|
||||
self.user_cache.process_pending_user_ids().await
|
||||
}
|
||||
|
||||
// Делегирование к reaction_manager
|
||||
pub async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.reaction_manager
|
||||
.get_message_available_reactions(chat_id, message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
emoji: String,
|
||||
) -> Result<(), String> {
|
||||
self.reaction_manager
|
||||
.toggle_reaction(chat_id, message_id, emoji)
|
||||
.await
|
||||
}
|
||||
|
||||
// Делегирование файловых операций
|
||||
|
||||
/// Скачивает файл по file_id и возвращает локальный путь.
|
||||
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
match functions::download_file(file_id, 1, 0, 0, true, self.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)
|
||||
} else {
|
||||
Err("Файл не скачан".to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательные методы
|
||||
pub fn client_id(&self) -> i32 {
|
||||
self.client_id
|
||||
}
|
||||
|
||||
pub async fn get_me(&self) -> Result<i64, String> {
|
||||
match functions::get_me(self.client_id).await {
|
||||
Ok(tdlib_rs::enums::User::User(user)) => Ok(user.id),
|
||||
Err(e) => Err(format!("Ошибка получения текущего пользователя: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Accessor methods для обратной совместимости
|
||||
pub fn auth_state(&self) -> &AuthState {
|
||||
&self.auth.state
|
||||
}
|
||||
|
||||
pub fn chats(&self) -> &[ChatInfo] {
|
||||
&self.chat_manager.chats
|
||||
}
|
||||
|
||||
pub fn update_chats<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>) -> R,
|
||||
{
|
||||
updater(&mut self.chat_manager.chats)
|
||||
}
|
||||
|
||||
pub fn update_chat<F>(&mut self, chat_id: ChatId, updater: F) -> bool
|
||||
where
|
||||
F: FnOnce(&mut ChatInfo),
|
||||
{
|
||||
let Some(chat) = self.chat_manager.chats.iter_mut().find(|c| c.id == chat_id) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
updater(chat);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_chat(&mut self, chat_id: ChatId) {
|
||||
self.chat_manager.chats.retain(|c| c.id != chat_id);
|
||||
}
|
||||
|
||||
pub fn push_chat(&mut self, chat: ChatInfo) {
|
||||
self.chat_manager.chats.push(chat);
|
||||
}
|
||||
|
||||
pub fn trim_chats_to_max_by_order(&mut self, max_chats: usize) {
|
||||
if self.chat_manager.chats.len() <= max_chats {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(min_idx) = self
|
||||
.chat_manager
|
||||
.chats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, chat)| chat.order)
|
||||
.map(|(idx, _)| idx)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_manager.chats.remove(min_idx);
|
||||
}
|
||||
|
||||
pub fn sort_chats_by_order(&mut self) {
|
||||
self.chat_manager
|
||||
.chats
|
||||
.sort_by(|a, b| b.order.cmp(&a.order));
|
||||
}
|
||||
|
||||
pub fn folders(&self) -> &[FolderInfo] {
|
||||
&self.chat_manager.folders
|
||||
}
|
||||
|
||||
pub fn update_folders<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>) -> R,
|
||||
{
|
||||
updater(&mut self.chat_manager.folders)
|
||||
}
|
||||
|
||||
pub fn set_folders(&mut self, folders: Vec<FolderInfo>) {
|
||||
self.chat_manager.folders = folders;
|
||||
}
|
||||
|
||||
pub fn current_chat_messages(&self) -> &[MessageInfo] {
|
||||
&self.message_manager.current_chat_messages
|
||||
}
|
||||
|
||||
pub fn clear_current_chat_messages(&mut self) {
|
||||
self.message_manager.current_chat_messages.clear();
|
||||
}
|
||||
|
||||
pub fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||
self.message_manager.current_chat_messages = messages;
|
||||
}
|
||||
|
||||
pub fn update_current_chat_messages<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>) -> R,
|
||||
{
|
||||
updater(&mut self.message_manager.current_chat_messages)
|
||||
}
|
||||
|
||||
pub fn update_current_chat_message<F>(&mut self, message_id: MessageId, updater: F) -> bool
|
||||
where
|
||||
F: FnOnce(&mut MessageInfo),
|
||||
{
|
||||
let Some(message) = self
|
||||
.message_manager
|
||||
.current_chat_messages
|
||||
.iter_mut()
|
||||
.find(|message| message.id() == message_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
updater(message);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn replace_current_chat_message(
|
||||
&mut self,
|
||||
message_id: MessageId,
|
||||
new_message: MessageInfo,
|
||||
) -> bool {
|
||||
self.update_current_chat_message(message_id, |message| {
|
||||
*message = new_message;
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.message_manager.current_chat_id
|
||||
}
|
||||
|
||||
pub fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.message_manager.current_chat_id = chat_id;
|
||||
}
|
||||
|
||||
pub fn current_pinned_message(&self) -> Option<&MessageInfo> {
|
||||
self.message_manager.current_pinned_message.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
self.message_manager.current_pinned_message = msg;
|
||||
}
|
||||
|
||||
pub fn typing_status(&self) -> Option<&(crate::types::UserId, String, std::time::Instant)> {
|
||||
self.chat_manager.typing_status.as_ref()
|
||||
}
|
||||
|
||||
pub fn set_typing_status(
|
||||
&mut self,
|
||||
status: Option<(crate::types::UserId, String, std::time::Instant)>,
|
||||
) {
|
||||
self.chat_manager.typing_status = status;
|
||||
}
|
||||
|
||||
pub fn pending_view_messages(&self) -> &[(crate::types::ChatId, Vec<crate::types::MessageId>)] {
|
||||
&self.message_manager.pending_view_messages
|
||||
}
|
||||
|
||||
pub fn enqueue_pending_view_messages(
|
||||
&mut self,
|
||||
chat_id: crate::types::ChatId,
|
||||
message_ids: Vec<crate::types::MessageId>,
|
||||
) {
|
||||
self.message_manager
|
||||
.pending_view_messages
|
||||
.push((chat_id, message_ids));
|
||||
}
|
||||
|
||||
pub fn pending_user_ids(&self) -> &[crate::types::UserId] {
|
||||
&self.user_cache.pending_user_ids
|
||||
}
|
||||
|
||||
pub fn queue_pending_user_id(&mut self, user_id: crate::types::UserId) {
|
||||
if !self.user_cache.pending_user_ids.contains(&user_id) {
|
||||
self.user_cache.pending_user_ids.push(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn main_chat_list_position(&self) -> i32 {
|
||||
self.chat_manager.main_chat_list_position
|
||||
}
|
||||
|
||||
pub fn set_main_chat_list_position(&mut self, position: i32) {
|
||||
self.chat_manager.main_chat_list_position = position;
|
||||
}
|
||||
|
||||
// User cache accessors
|
||||
pub fn user_cache(&self) -> &UserCache {
|
||||
&self.user_cache
|
||||
}
|
||||
|
||||
pub fn update_user_cache<F, R>(&mut self, updater: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut UserCache) -> R,
|
||||
{
|
||||
updater(&mut self.user_cache)
|
||||
}
|
||||
|
||||
// ==================== Helper методы для упрощения обработки updates ====================
|
||||
|
||||
/// Обрабатываем одно обновление от TDLib
|
||||
pub fn handle_update(&mut self, update: Update) {
|
||||
match update {
|
||||
Update::AuthorizationState(state) => {
|
||||
crate::tdlib::update_handlers::handle_auth_state(self, state.authorization_state);
|
||||
}
|
||||
Update::NewChat(new_chat) => {
|
||||
// new_chat.chat is already a Chat struct, wrap it in TdChat enum
|
||||
let td_chat = TdChat::Chat(new_chat.chat.clone());
|
||||
crate::tdlib::chat_helpers::add_or_update_chat(self, &td_chat);
|
||||
}
|
||||
Update::ChatLastMessage(update) => {
|
||||
let chat_id = ChatId::new(update.chat_id);
|
||||
let (last_message_text, last_message_date) = update
|
||||
.last_message
|
||||
.as_ref()
|
||||
.map(|msg| (Self::extract_message_text_static(msg).0, msg.date))
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
|
||||
chat.last_message = last_message_text;
|
||||
chat.last_message_date = last_message_date;
|
||||
});
|
||||
|
||||
// Обновляем позиции если они пришли
|
||||
for pos in update
|
||||
.positions
|
||||
.iter()
|
||||
.filter(|p| matches!(p.list, ChatList::Main))
|
||||
{
|
||||
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
|
||||
chat.order = pos.order;
|
||||
chat.is_pinned = pos.is_pinned;
|
||||
});
|
||||
}
|
||||
|
||||
// Пересортируем по order
|
||||
self.sort_chats_by_order();
|
||||
}
|
||||
Update::ChatReadInbox(update) => {
|
||||
crate::tdlib::chat_helpers::update_chat(
|
||||
self,
|
||||
ChatId::new(update.chat_id),
|
||||
|chat| {
|
||||
chat.unread_count = update.unread_count;
|
||||
},
|
||||
);
|
||||
}
|
||||
Update::ChatUnreadMentionCount(update) => {
|
||||
crate::tdlib::chat_helpers::update_chat(
|
||||
self,
|
||||
ChatId::new(update.chat_id),
|
||||
|chat| {
|
||||
chat.unread_mention_count = update.unread_mention_count;
|
||||
},
|
||||
);
|
||||
}
|
||||
Update::ChatNotificationSettings(update) => {
|
||||
crate::tdlib::chat_helpers::update_chat(
|
||||
self,
|
||||
ChatId::new(update.chat_id),
|
||||
|chat| {
|
||||
// mute_for > 0 означает что чат замьючен
|
||||
chat.is_muted = update.notification_settings.mute_for > 0;
|
||||
},
|
||||
);
|
||||
}
|
||||
Update::ChatReadOutbox(update) => {
|
||||
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
|
||||
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
|
||||
crate::tdlib::chat_helpers::update_chat(
|
||||
self,
|
||||
ChatId::new(update.chat_id),
|
||||
|chat| {
|
||||
chat.last_read_outbox_message_id = last_read_msg_id;
|
||||
},
|
||||
);
|
||||
// Если это текущий открытый чат — обновляем is_read у сообщений
|
||||
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
|
||||
self.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
if msg.is_outgoing() && msg.id() <= last_read_msg_id {
|
||||
msg.state.is_read = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Update::ChatPosition(update) => {
|
||||
crate::tdlib::update_handlers::handle_chat_position_update(self, update);
|
||||
}
|
||||
Update::NewMessage(new_msg) => {
|
||||
crate::tdlib::update_handlers::handle_new_message_update(self, new_msg);
|
||||
}
|
||||
Update::User(update) => {
|
||||
crate::tdlib::update_handlers::handle_user_update(self, update);
|
||||
}
|
||||
Update::ChatFolders(update) => {
|
||||
// Обновляем список папок
|
||||
self.set_folders(
|
||||
update
|
||||
.chat_folders
|
||||
.into_iter()
|
||||
.map(|f| FolderInfo { id: f.id, name: f.title })
|
||||
.collect(),
|
||||
);
|
||||
self.set_main_chat_list_position(update.main_chat_list_position);
|
||||
}
|
||||
Update::UserStatus(update) => {
|
||||
// Обновляем онлайн-статус пользователя
|
||||
let status = match update.status {
|
||||
UserStatus::Online(_) => UserOnlineStatus::Online,
|
||||
UserStatus::Offline(offline) => UserOnlineStatus::Offline(offline.was_online),
|
||||
UserStatus::Recently(_) => UserOnlineStatus::Recently,
|
||||
UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek,
|
||||
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
|
||||
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
|
||||
};
|
||||
self.update_user_cache(|cache| {
|
||||
cache
|
||||
.user_statuses
|
||||
.insert(UserId::new(update.user_id), status);
|
||||
});
|
||||
}
|
||||
Update::ConnectionState(update) => {
|
||||
// Обновляем состояние сетевого соединения
|
||||
self.network_state = match update.state {
|
||||
ConnectionState::WaitingForNetwork => NetworkState::WaitingForNetwork,
|
||||
ConnectionState::ConnectingToProxy => NetworkState::ConnectingToProxy,
|
||||
ConnectionState::Connecting => NetworkState::Connecting,
|
||||
ConnectionState::Updating => NetworkState::Updating,
|
||||
ConnectionState::Ready => NetworkState::Ready,
|
||||
};
|
||||
}
|
||||
Update::ChatAction(update) => {
|
||||
crate::tdlib::update_handlers::handle_chat_action_update(self, update);
|
||||
}
|
||||
Update::ChatDraftMessage(update) => {
|
||||
crate::tdlib::update_handlers::handle_chat_draft_message_update(self, update);
|
||||
}
|
||||
Update::MessageInteractionInfo(update) => {
|
||||
crate::tdlib::update_handlers::handle_message_interaction_info_update(self, update);
|
||||
}
|
||||
Update::MessageSendSucceeded(update) => {
|
||||
crate::tdlib::update_handlers::handle_message_send_succeeded_update(self, update);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
pub fn extract_message_text_static(
|
||||
message: &TdMessage,
|
||||
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
|
||||
use tdlib_rs::enums::MessageContent;
|
||||
match &message.content {
|
||||
MessageContent::MessageText(text) => {
|
||||
(text.text.text.clone(), text.text.entities.clone())
|
||||
}
|
||||
_ => (String::new(), Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Recreates the TDLib client with a new database path.
|
||||
///
|
||||
/// Closes the old client, creates a new one, and spawns TDLib parameter initialization.
|
||||
pub async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||
// 1. Close old client
|
||||
let _ = functions::close(self.client_id).await;
|
||||
|
||||
// 2. Create new client
|
||||
let new_client = TdClient::new(TdClientConfig {
|
||||
credentials: TdCredentials {
|
||||
api_id: self.api_id,
|
||||
api_hash: self.api_hash.clone(),
|
||||
},
|
||||
db_path,
|
||||
});
|
||||
|
||||
// 3. Spawn set_tdlib_parameters for new client
|
||||
let new_client_id = new_client.client_id;
|
||||
let api_id = new_client.api_id;
|
||||
let api_hash = new_client.api_hash.clone();
|
||||
let db_path_str = new_client.db_path.to_string_lossy().to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = functions::set_tdlib_parameters(
|
||||
false,
|
||||
db_path_str,
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
api_id,
|
||||
api_hash,
|
||||
"en".to_string(),
|
||||
"Desktop".to_string(),
|
||||
"".to_string(),
|
||||
env!("CARGO_PKG_VERSION").to_string(),
|
||||
new_client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Replace self
|
||||
*self = new_client;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
|
||||
use tdlib_rs::enums::MessageContent;
|
||||
match content {
|
||||
MessageContent::MessageText(text) => text.text.text.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
332
crates/tele-core/src/tdlib/client_impl.rs
Normal file
332
crates/tele-core/src/tdlib/client_impl.rs
Normal file
@@ -0,0 +1,332 @@
|
||||
//! Implementation of TdClientTrait for TdClient
|
||||
//!
|
||||
//! This file contains the trait implementation that delegates to existing TdClient methods.
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::r#trait::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, ReactionClient, UpdateClient, UserClient,
|
||||
};
|
||||
use super::{
|
||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||
UserOnlineStatus,
|
||||
};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
#[async_trait]
|
||||
impl AuthClient for TdClient {
|
||||
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||
self.send_phone_number(phone).await
|
||||
}
|
||||
|
||||
async fn send_code(&self, code: String) -> Result<(), String> {
|
||||
self.send_code(code).await
|
||||
}
|
||||
|
||||
async fn send_password(&self, password: String) -> Result<(), String> {
|
||||
self.send_password(password).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatClient for TdClient {
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||
self.load_chats(limit).await
|
||||
}
|
||||
|
||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
|
||||
self.load_folder_chats(folder_id, limit).await
|
||||
}
|
||||
|
||||
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||
self.leave_chat(chat_id).await
|
||||
}
|
||||
|
||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||
self.get_profile_info(chat_id).await
|
||||
}
|
||||
|
||||
fn chats(&self) -> &[ChatInfo] {
|
||||
self.chats()
|
||||
}
|
||||
|
||||
fn folders(&self) -> &[FolderInfo] {
|
||||
self.folders()
|
||||
}
|
||||
|
||||
fn main_chat_list_position(&self) -> i32 {
|
||||
self.main_chat_list_position()
|
||||
}
|
||||
|
||||
fn set_main_chat_list_position(&mut self, position: i32) {
|
||||
self.set_main_chat_list_position(position)
|
||||
}
|
||||
|
||||
fn update_chats<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>),
|
||||
{
|
||||
TdClient::update_chats(self, updater);
|
||||
}
|
||||
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>),
|
||||
{
|
||||
TdClient::update_folders(self, updater);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatActionClient for TdClient {
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||
self.send_chat_action(chat_id, action).await
|
||||
}
|
||||
|
||||
fn clear_stale_typing_status(&mut self) -> bool {
|
||||
self.clear_stale_typing_status()
|
||||
}
|
||||
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||
self.typing_status()
|
||||
}
|
||||
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
|
||||
self.set_typing_status(status)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageClient for TdClient {
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.get_chat_history(chat_id, limit).await
|
||||
}
|
||||
|
||||
async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.load_older_messages(chat_id, from_message_id).await
|
||||
}
|
||||
|
||||
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
||||
self.get_pinned_messages(chat_id).await
|
||||
}
|
||||
|
||||
async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
||||
self.load_current_pinned_message(chat_id).await
|
||||
}
|
||||
|
||||
async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.search_messages(chat_id, query).await
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
TdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
TdClient::edit_message(self, chat_id, message_id, new_text).await
|
||||
}
|
||||
|
||||
async fn delete_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
self.message_manager
|
||||
.delete_messages(chat_id, message_ids, revoke)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn forward_messages(
|
||||
&mut self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
self.message_manager
|
||||
.forward_messages(to_chat_id, from_chat_id, message_ids)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
self.set_draft_message(chat_id, text).await
|
||||
}
|
||||
|
||||
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> {
|
||||
Cow::Borrowed(self.current_chat_messages())
|
||||
}
|
||||
|
||||
fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.current_chat_id()
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
self.current_pinned_message().cloned()
|
||||
}
|
||||
|
||||
fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.push_message(msg)
|
||||
}
|
||||
|
||||
async fn fetch_missing_reply_info(&mut self) {
|
||||
self.fetch_missing_reply_info().await
|
||||
}
|
||||
|
||||
async fn process_pending_view_messages(&mut self) {
|
||||
self.process_pending_view_messages().await
|
||||
}
|
||||
|
||||
fn clear_current_chat_messages(&mut self) {
|
||||
TdClient::clear_current_chat_messages(self)
|
||||
}
|
||||
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||
TdClient::set_current_chat_messages(self, messages);
|
||||
}
|
||||
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>),
|
||||
{
|
||||
TdClient::update_current_chat_messages(self, updater);
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
self.set_current_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
self.set_current_pinned_message(msg)
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
self.pending_view_messages()
|
||||
}
|
||||
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
self.enqueue_pending_view_messages(chat_id, message_ids);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserClient for TdClient {
|
||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
self.get_user_status_by_chat_id(chat_id)
|
||||
}
|
||||
|
||||
fn pending_user_ids(&self) -> &[UserId] {
|
||||
self.pending_user_ids()
|
||||
}
|
||||
|
||||
fn user_cache(&self) -> &UserCache {
|
||||
self.user_cache()
|
||||
}
|
||||
|
||||
fn update_user_cache<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut UserCache),
|
||||
{
|
||||
TdClient::update_user_cache(self, updater);
|
||||
}
|
||||
|
||||
async fn process_pending_user_ids(&mut self) {
|
||||
self.process_pending_user_ids().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReactionClient for TdClient {
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
self.get_message_available_reactions(chat_id, message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reaction: String,
|
||||
) -> Result<(), String> {
|
||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileClient for TdClient {
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||
// Voice notes use the same download mechanism as photos
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientState for TdClient {
|
||||
fn client_id(&self) -> i32 {
|
||||
self.client_id()
|
||||
}
|
||||
|
||||
async fn get_me(&self) -> Result<i64, String> {
|
||||
self.get_me().await
|
||||
}
|
||||
|
||||
fn auth_state(&self) -> &AuthState {
|
||||
self.auth_state()
|
||||
}
|
||||
|
||||
fn network_state(&self) -> super::types::NetworkState {
|
||||
self.network_state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountClient for TdClient {
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||
TdClient::recreate_client(self, db_path).await
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateClient for TdClient {
|
||||
fn handle_update(&mut self, update: Update) {
|
||||
// Delegate to the real implementation
|
||||
TdClient::handle_update(self, update)
|
||||
}
|
||||
|
||||
fn drain_incoming_message_events(&mut self) -> Vec<super::IncomingMessageEvent> {
|
||||
TdClient::drain_incoming_message_events(self)
|
||||
}
|
||||
}
|
||||
213
crates/tele-core/src/tdlib/message_conversion.rs
Normal file
213
crates/tele-core/src/tdlib/message_conversion.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
//! Вспомогательные функции для конвертации TDLib сообщений в MessageInfo
|
||||
//!
|
||||
//! Этот модуль содержит функции для извлечения различных частей сообщения
|
||||
//! из TDLib Message и конвертации их в наш внутренний формат MessageInfo.
|
||||
|
||||
use crate::types::MessageId;
|
||||
use tdlib_rs::enums::{MessageContent, MessageSender};
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use super::types::{
|
||||
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
|
||||
VoiceDownloadState, VoiceInfo,
|
||||
};
|
||||
|
||||
/// Извлекает текст контента из TDLib Message
|
||||
///
|
||||
/// Обрабатывает различные типы сообщений (текст, фото, видео, стикеры, и т.д.)
|
||||
/// и возвращает текстовое представление.
|
||||
pub fn extract_content_text(msg: &TdMessage) -> String {
|
||||
match &msg.content {
|
||||
MessageContent::MessageText(t) => t.text.text.clone(),
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
let caption_text = p.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"📷 [Фото]".to_string()
|
||||
} else {
|
||||
format!("📷 {}", caption_text)
|
||||
}
|
||||
}
|
||||
MessageContent::MessageVideo(v) => {
|
||||
let caption_text = v.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"[Видео]".to_string()
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
MessageContent::MessageDocument(d) => {
|
||||
let caption_text = d.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
format!("[Файл: {}]", d.document.file_name)
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
MessageContent::MessageSticker(s) => {
|
||||
format!("[Стикер: {}]", s.sticker.emoji)
|
||||
}
|
||||
MessageContent::MessageAnimation(a) => {
|
||||
let caption_text = a.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"[GIF]".to_string()
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
MessageContent::MessageVoiceNote(v) => {
|
||||
let duration = v.voice_note.duration;
|
||||
let caption_text = v.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
format!("🎤 [Голосовое {:.0}s]", duration)
|
||||
} else {
|
||||
format!("🎤 {} ({:.0}s)", caption_text, duration)
|
||||
}
|
||||
}
|
||||
MessageContent::MessageAudio(a) => {
|
||||
let caption_text = a.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
let title = a.audio.title.clone();
|
||||
let performer = a.audio.performer.clone();
|
||||
if !title.is_empty() || !performer.is_empty() {
|
||||
format!("[Аудио: {} - {}]", performer, title)
|
||||
} else {
|
||||
"[Аудио]".to_string()
|
||||
}
|
||||
} else {
|
||||
caption_text
|
||||
}
|
||||
}
|
||||
_ => "[Неподдерживаемый тип сообщения]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает entities (форматирование) из TDLib Message
|
||||
pub fn extract_entities(msg: &TdMessage) -> Vec<tdlib_rs::types::TextEntity> {
|
||||
if let MessageContent::MessageText(t) = &msg.content {
|
||||
t.text.entities.clone()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает имя отправителя из TDLib Message
|
||||
///
|
||||
/// Для пользователей делает API вызов get_user для получения имени.
|
||||
/// Для чатов возвращает ID чата.
|
||||
pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
|
||||
match &msg.sender_id {
|
||||
MessageSender::User(user) => {
|
||||
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
|
||||
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
|
||||
.trim()
|
||||
.to_string(),
|
||||
_ => format!("User {}", user.user_id),
|
||||
}
|
||||
}
|
||||
MessageSender::Chat(chat) => format!("Chat {}", chat.chat_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает информацию о пересылке из TDLib Message
|
||||
pub fn extract_forward_info(msg: &TdMessage) -> Option<ForwardInfo> {
|
||||
msg.forward_info.as_ref().and_then(|fi| {
|
||||
if let tdlib_rs::enums::MessageOrigin::User(origin_user) = &fi.origin {
|
||||
Some(ForwardInfo {
|
||||
sender_name: format!("User {}", origin_user.sender_user_id),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает информацию об ответе из TDLib Message
|
||||
pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
|
||||
msg.reply_to.as_ref().and_then(|reply_to| {
|
||||
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
|
||||
Some(ReplyInfo {
|
||||
message_id: MessageId::new(reply_msg.message_id),
|
||||
sender_name: "Unknown".to_string(),
|
||||
text: "...".to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает информацию о медиа-контенте из TDLib Message
|
||||
///
|
||||
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
|
||||
/// Возвращает None для не-медийных типов сообщений.
|
||||
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
|
||||
match &msg.content {
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
// Берём лучший (последний = самый большой) размер фото
|
||||
let best_size = p.photo.sizes.last()?;
|
||||
let file_id = best_size.photo.id;
|
||||
let width = best_size.width;
|
||||
let height = best_size.height;
|
||||
|
||||
// Проверяем, скачан ли файл
|
||||
let download_state = if !best_size.photo.local.path.is_empty()
|
||||
&& best_size.photo.local.is_downloading_completed
|
||||
{
|
||||
PhotoDownloadState::Downloaded(best_size.photo.local.path.clone())
|
||||
} else {
|
||||
PhotoDownloadState::NotDownloaded
|
||||
};
|
||||
|
||||
Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
|
||||
}
|
||||
MessageContent::MessageVoiceNote(v) => {
|
||||
let file_id = v.voice_note.voice.id;
|
||||
let duration = v.voice_note.duration;
|
||||
let mime_type = v.voice_note.mime_type.clone();
|
||||
let waveform = v.voice_note.waveform.clone();
|
||||
|
||||
// Проверяем, скачан ли файл
|
||||
let download_state = if !v.voice_note.voice.local.path.is_empty()
|
||||
&& v.voice_note.voice.local.is_downloading_completed
|
||||
{
|
||||
VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone())
|
||||
} else {
|
||||
VoiceDownloadState::NotDownloaded
|
||||
};
|
||||
|
||||
Some(MediaInfo::Voice(VoiceInfo {
|
||||
file_id,
|
||||
duration,
|
||||
mime_type,
|
||||
waveform,
|
||||
download_state,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает реакции из TDLib Message
|
||||
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
||||
msg.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|ii| ii.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
if let tdlib_rs::enums::ReactionType::Emoji(emoji_type) = &r.r#type {
|
||||
Some(ReactionInfo {
|
||||
emoji: emoji_type.emoji.clone(),
|
||||
count: r.total_count,
|
||||
is_chosen: r.is_chosen,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
234
crates/tele-core/src/tdlib/message_converter.rs
Normal file
234
crates/tele-core/src/tdlib/message_converter.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Message conversion utilities for transforming TDLib messages.
|
||||
//!
|
||||
//! This module contains functions for converting TDLib message formats
|
||||
//! to the application's internal MessageInfo format, including extraction
|
||||
//! of replies, forwards, and reactions.
|
||||
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use super::client::TdClient;
|
||||
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
|
||||
|
||||
/// Конвертирует TDLib сообщение в MessageInfo
|
||||
pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
|
||||
let sender_name = match &message.sender_id {
|
||||
tdlib_rs::enums::MessageSender::User(user) => {
|
||||
// Пробуем получить имя из кеша (get обновляет LRU порядок)
|
||||
let user_id = UserId::new(user.user_id);
|
||||
client
|
||||
.user_cache
|
||||
.user_names
|
||||
.get(&user_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
// Добавляем в очередь для загрузки
|
||||
client.queue_pending_user_id(user_id);
|
||||
format!("User_{}", user_id.as_i64())
|
||||
})
|
||||
}
|
||||
tdlib_rs::enums::MessageSender::Chat(chat) => {
|
||||
// Для чатов используем название чата
|
||||
let sender_chat_id = ChatId::new(chat.chat_id);
|
||||
client
|
||||
.chats()
|
||||
.iter()
|
||||
.find(|c| c.id == sender_chat_id)
|
||||
.map(|c| c.title.clone())
|
||||
.unwrap_or_else(|| format!("Chat_{}", sender_chat_id.as_i64()))
|
||||
}
|
||||
};
|
||||
|
||||
// Определяем, прочитано ли исходящее сообщение
|
||||
let message_id = MessageId::new(message.id);
|
||||
let is_read = if message.is_outgoing {
|
||||
// Сообщение прочитано, если его ID <= last_read_outbox_message_id чата
|
||||
client
|
||||
.chats()
|
||||
.iter()
|
||||
.find(|c| c.id == chat_id)
|
||||
.map(|c| message_id <= c.last_read_outbox_message_id)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
true // Входящие сообщения не показывают галочки
|
||||
};
|
||||
|
||||
let (content, entities) = TdClient::extract_message_text_static(message);
|
||||
|
||||
// Извлекаем информацию о reply
|
||||
let reply_to = extract_reply_info(client, message);
|
||||
|
||||
// Извлекаем информацию о forward
|
||||
let forward_from = extract_forward_info(client, message);
|
||||
|
||||
// Извлекаем реакции
|
||||
let reactions = extract_reactions(client, message);
|
||||
|
||||
// Используем MessageBuilder для более читабельного создания
|
||||
let mut builder = crate::tdlib::MessageBuilder::new(message_id)
|
||||
.sender_name(sender_name)
|
||||
.text(content)
|
||||
.entities(entities)
|
||||
.date(message.date)
|
||||
.edit_date(message.edit_date)
|
||||
.media_album_id(message.media_album_id);
|
||||
|
||||
// Применяем флаги
|
||||
if message.is_outgoing {
|
||||
builder = builder.outgoing();
|
||||
}
|
||||
if is_read {
|
||||
builder = builder.read();
|
||||
}
|
||||
if message.can_be_edited {
|
||||
builder = builder.editable();
|
||||
}
|
||||
if message.can_be_deleted_only_for_self {
|
||||
builder = builder.deletable_for_self();
|
||||
}
|
||||
if message.can_be_deleted_for_all_users {
|
||||
builder = builder.deletable_for_all();
|
||||
}
|
||||
|
||||
// Добавляем опциональные данные
|
||||
if let Some(reply) = reply_to {
|
||||
builder = builder.reply_to(reply);
|
||||
}
|
||||
if let Some(forward) = forward_from {
|
||||
builder = builder.forward_from(forward);
|
||||
}
|
||||
if !reactions.is_empty() {
|
||||
builder = builder.reactions(reactions);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// Извлекает информацию о reply из сообщения
|
||||
pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<ReplyInfo> {
|
||||
use tdlib_rs::enums::MessageReplyTo;
|
||||
|
||||
match &message.reply_to {
|
||||
Some(MessageReplyTo::Message(reply)) => {
|
||||
// Получаем имя отправителя из origin или ищем сообщение в текущем списке
|
||||
let sender_name = reply
|
||||
.origin
|
||||
.as_ref()
|
||||
.map(get_origin_sender_name)
|
||||
.unwrap_or_else(|| {
|
||||
// Пробуем найти оригинальное сообщение в текущем списке
|
||||
let reply_msg_id = MessageId::new(reply.message_id);
|
||||
client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == reply_msg_id)
|
||||
.map(|m| m.sender_name().to_string())
|
||||
.unwrap_or_else(|| "...".to_string())
|
||||
});
|
||||
|
||||
// Получаем текст из content или quote
|
||||
let reply_msg_id = MessageId::new(reply.message_id);
|
||||
let text = reply
|
||||
.quote
|
||||
.as_ref()
|
||||
.map(|q| q.text.text.clone())
|
||||
.or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
|
||||
.unwrap_or_else(|| {
|
||||
// Пробуем найти в текущих сообщениях
|
||||
client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == reply_msg_id)
|
||||
.map(|m| m.text().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает информацию о forward из сообщения
|
||||
pub fn extract_forward_info(_client: &TdClient, message: &TdMessage) -> Option<ForwardInfo> {
|
||||
message.forward_info.as_ref().map(|info| {
|
||||
let sender_name = get_origin_sender_name(&info.origin);
|
||||
ForwardInfo { sender_name }
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает реакции из сообщения
|
||||
pub fn extract_reactions(_client: &TdClient, message: &TdMessage) -> Vec<ReactionInfo> {
|
||||
message
|
||||
.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|reaction| {
|
||||
let emoji = match &reaction.r#type {
|
||||
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
|
||||
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
|
||||
};
|
||||
|
||||
Some(ReactionInfo {
|
||||
emoji,
|
||||
count: reaction.total_count,
|
||||
is_chosen: reaction.is_chosen,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Получает имя отправителя из MessageOrigin
|
||||
fn get_origin_sender_name(origin: &tdlib_rs::enums::MessageOrigin) -> String {
|
||||
use tdlib_rs::enums::MessageOrigin;
|
||||
|
||||
match origin {
|
||||
MessageOrigin::User(u) => format!("User_{}", u.sender_user_id),
|
||||
MessageOrigin::Chat(c) => format!("Chat_{}", c.sender_chat_id),
|
||||
MessageOrigin::Channel(c) => c.author_signature.clone(),
|
||||
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обновляет reply info для сообщений, где данные не были загружены
|
||||
/// Вызывается после загрузки истории, когда все сообщения уже в списке
|
||||
#[allow(dead_code)]
|
||||
pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
|
||||
// Собираем данные для обновления (id -> (sender_name, content))
|
||||
let msg_data: std::collections::HashMap<i64, (String, String)> = client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
|
||||
.collect();
|
||||
|
||||
// Обновляем reply_to для сообщений с неполными данными
|
||||
client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
let Some(ref mut reply) = msg.interactions.reply_to else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Если sender_name = "..." или text пустой — пробуем заполнить
|
||||
if reply.sender_name != "..." && !reply.text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if reply.sender_name == "..." {
|
||||
reply.sender_name = sender.clone();
|
||||
}
|
||||
if reply.text.is_empty() {
|
||||
reply.text = content.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
142
crates/tele-core/src/tdlib/messages/convert.rs
Normal file
142
crates/tele-core/src/tdlib/messages/convert.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
|
||||
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use crate::tdlib::types::{MessageBuilder, MessageInfo};
|
||||
|
||||
use super::MessageManager;
|
||||
|
||||
impl MessageManager {
|
||||
/// Конвертировать TdMessage в MessageInfo
|
||||
pub(crate) async fn convert_message(
|
||||
&self,
|
||||
msg: &TdMessage,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Option<MessageInfo> {
|
||||
use crate::tdlib::message_conversion::{
|
||||
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
|
||||
extract_reactions, extract_reply_info, extract_sender_name,
|
||||
};
|
||||
|
||||
// Извлекаем все части сообщения используя вспомогательные функции
|
||||
let content_text = extract_content_text(msg);
|
||||
let entities = extract_entities(msg);
|
||||
let sender_name = extract_sender_name(msg, self.client_id).await;
|
||||
let forward_from = extract_forward_info(msg);
|
||||
let reply_to = extract_reply_info(msg);
|
||||
let reactions = extract_reactions(msg);
|
||||
let media = extract_media_info(msg);
|
||||
|
||||
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||
.sender_name(sender_name)
|
||||
.text(content_text)
|
||||
.entities(entities)
|
||||
.date(msg.date)
|
||||
.edit_date(msg.edit_date)
|
||||
.media_album_id(msg.media_album_id);
|
||||
|
||||
if msg.is_outgoing {
|
||||
builder = builder.outgoing();
|
||||
} else {
|
||||
builder = builder.incoming();
|
||||
}
|
||||
|
||||
let is_read = !msg.is_outgoing || msg.id <= last_read_outbox_message_id.as_i64();
|
||||
if is_read {
|
||||
builder = builder.read();
|
||||
} else {
|
||||
builder = builder.unread();
|
||||
}
|
||||
|
||||
if msg.can_be_edited {
|
||||
builder = builder.editable();
|
||||
}
|
||||
|
||||
if msg.can_be_deleted_only_for_self {
|
||||
builder = builder.deletable_for_self();
|
||||
}
|
||||
|
||||
if msg.can_be_deleted_for_all_users {
|
||||
builder = builder.deletable_for_all();
|
||||
}
|
||||
|
||||
if let Some(reply) = reply_to {
|
||||
builder = builder.reply_to(reply);
|
||||
}
|
||||
|
||||
if let Some(forward) = forward_from {
|
||||
builder = builder.forward_from(forward);
|
||||
}
|
||||
|
||||
builder = builder.reactions(reactions);
|
||||
|
||||
if let Some(media) = media {
|
||||
builder = builder.media(media);
|
||||
}
|
||||
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// Загружает недостающую информацию об исходных сообщениях для ответов.
|
||||
///
|
||||
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
|
||||
/// полную информацию (имя отправителя, текст) из TDLib.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
|
||||
pub async fn fetch_missing_reply_info(&mut self) {
|
||||
// Early return if no chat selected
|
||||
let Some(chat_id) = self.current_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Collect message IDs with missing reply info using filter_map
|
||||
let to_fetch: Vec<MessageId> = self
|
||||
.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();
|
||||
|
||||
// Fetch and update each missing message
|
||||
for message_id in to_fetch {
|
||||
self.fetch_and_update_reply(chat_id, message_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает одно сообщение и обновляет reply информацию.
|
||||
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
|
||||
// Try to fetch the original message
|
||||
let Ok(original_msg_enum) =
|
||||
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
||||
let Some(orig_info) = self.convert_message(&original_msg, MessageId::new(0)).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Extract text preview (first 50 chars)
|
||||
let text_preview: String = orig_info.content.text.chars().take(50).collect();
|
||||
|
||||
// Update reply info in all messages that reference this message
|
||||
self.current_chat_messages
|
||||
.iter_mut()
|
||||
.filter_map(|msg| msg.interactions.reply_to.as_mut())
|
||||
.filter(|reply| reply.message_id == message_id)
|
||||
.for_each(|reply| {
|
||||
reply.sender_name = orig_info.metadata.sender_name.clone();
|
||||
reply.text = text_preview.clone();
|
||||
});
|
||||
}
|
||||
}
|
||||
102
crates/tele-core/src/tdlib/messages/mod.rs
Normal file
102
crates/tele-core/src/tdlib/messages/mod.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
//! Message management: storage, conversion, and TDLib API operations.
|
||||
|
||||
mod convert;
|
||||
mod operations;
|
||||
|
||||
use crate::constants::MAX_MESSAGES_IN_CHAT;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
use super::types::MessageInfo;
|
||||
|
||||
/// Менеджер сообщений TDLib.
|
||||
///
|
||||
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
|
||||
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
|
||||
///
|
||||
/// # Основные возможности
|
||||
///
|
||||
/// - Загрузка истории сообщений чата
|
||||
/// - Отправка текстовых сообщений с поддержкой Markdown
|
||||
/// - Редактирование и удаление сообщений
|
||||
/// - Пересылка сообщений между чатами
|
||||
/// - Поиск сообщений по тексту
|
||||
/// - Управление закрепленными сообщениями
|
||||
/// - Управление черновиками
|
||||
/// - Автоматическая отметка сообщений как прочитанных
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut msg_manager = MessageManager::new(client_id);
|
||||
///
|
||||
/// // Загрузить историю чата
|
||||
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
|
||||
///
|
||||
/// // Отправить сообщение
|
||||
/// let msg = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Hello, **world**!".to_string(),
|
||||
/// None,
|
||||
/// None
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub struct MessageManager {
|
||||
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
|
||||
pub current_chat_messages: Vec<MessageInfo>,
|
||||
|
||||
/// ID текущего открытого чата.
|
||||
pub current_chat_id: Option<ChatId>,
|
||||
|
||||
/// Текущее закрепленное сообщение открытого чата.
|
||||
pub current_pinned_message: Option<MessageInfo>,
|
||||
|
||||
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
|
||||
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
|
||||
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
pub(crate) client_id: i32,
|
||||
}
|
||||
|
||||
impl MessageManager {
|
||||
/// Создает новый менеджер сообщений.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self {
|
||||
current_chat_messages: Vec::new(),
|
||||
current_chat_id: None,
|
||||
current_pinned_message: None,
|
||||
pending_view_messages: Vec::new(),
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Добавляет сообщение в список текущего чата.
|
||||
///
|
||||
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
|
||||
/// удаляя старые сообщения при превышении лимита.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `msg` - Сообщение для добавления
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Сообщение добавляется в конец списка. При превышении лимита
|
||||
/// удаляются самые старые сообщения из начала списка.
|
||||
pub fn push_message(&mut self, msg: MessageInfo) {
|
||||
self.current_chat_messages.push(msg); // Добавляем в конец
|
||||
|
||||
// Ограничиваем размер списка (удаляем старые с начала)
|
||||
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
||||
self.current_chat_messages
|
||||
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
|
||||
}
|
||||
}
|
||||
}
|
||||
625
crates/tele-core/src/tdlib/messages/operations.rs
Normal file
625
crates/tele-core/src/tdlib/messages/operations.rs
Normal file
@@ -0,0 +1,625 @@
|
||||
//! TDLib message API operations: history, send, edit, delete, forward, search.
|
||||
|
||||
use crate::constants::TDLIB_MESSAGE_LIMIT;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::enums::{
|
||||
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
|
||||
};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::{
|
||||
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
|
||||
};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::tdlib::types::{MessageInfo, ReplyInfo};
|
||||
|
||||
use super::MessageManager;
|
||||
|
||||
impl MessageManager {
|
||||
/// Загружает историю сообщений чата с динамической подгрузкой.
|
||||
///
|
||||
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
|
||||
/// Продолжает загрузку пока не будет достигнут `limit` или пока TDLib отдает сообщения.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `limit` - Желаемое минимальное количество сообщений (для заполнения экрана)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список сообщений (от старых к новым)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузить достаточно сообщений для экрана высотой 30 строк
|
||||
/// let messages = msg_manager.get_chat_history(chat_id, 30).await?;
|
||||
/// ```
|
||||
pub async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
// ВАЖНО: Сначала открываем чат в TDLib
|
||||
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
|
||||
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
|
||||
|
||||
// Открываем чат - TDLib начнет синхронизацию автоматически
|
||||
|
||||
// НЕ устанавливаем current_chat_id здесь!
|
||||
// Он будет установлен снаружи ПОСЛЕ сохранения истории
|
||||
// Это предотвращает race condition с Update::NewMessage
|
||||
|
||||
let mut all_messages = Vec::new();
|
||||
let mut from_message_id = 0i64; // 0 = начинаем с последних сообщений
|
||||
let max_attempts_per_chunk = 20; // Максимум попыток на чанк
|
||||
let mut consecutive_empty_results = 0; // Счетчик пустых результатов подряд
|
||||
|
||||
// Загружаем чанками по TDLIB_MESSAGE_LIMIT пока не достигнем limit
|
||||
while (all_messages.len() as i32) < limit {
|
||||
let remaining = limit - (all_messages.len() as i32);
|
||||
let chunk_size = std::cmp::min(TDLIB_MESSAGE_LIMIT, remaining);
|
||||
|
||||
let mut chunk_loaded = false;
|
||||
|
||||
// Пробуем загрузить чанк (TDLib подгружает с сервера по мере готовности)
|
||||
for attempt in 1..=max_attempts_per_chunk {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
from_message_id,
|
||||
0, // offset
|
||||
chunk_size,
|
||||
false, // only_local - false means can fetch from server
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let messages_obj = match result {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(obj)) => obj,
|
||||
Err(e) => {
|
||||
// При первой загрузке (from_message_id == 0) возвращаем ошибку
|
||||
// При последующих чанках - прерываем цикл (возможно кончились сообщения)
|
||||
if all_messages.is_empty() {
|
||||
return Err(format!("Ошибка загрузки истории: {:?}", e));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let received_count = messages_obj.messages.len();
|
||||
|
||||
// Если получили пустой результат
|
||||
if messages_obj.messages.is_empty() {
|
||||
consecutive_empty_results += 1;
|
||||
// Если несколько раз подряд пусто - прерываем
|
||||
if consecutive_empty_results >= 3 {
|
||||
break;
|
||||
}
|
||||
// Пробуем еще раз
|
||||
continue;
|
||||
}
|
||||
|
||||
// Получили сообщения - сбрасываем счетчик
|
||||
consecutive_empty_results = 0;
|
||||
|
||||
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
|
||||
// TDLib может подгружать данные с сервера постепенно
|
||||
if all_messages.is_empty()
|
||||
&& received_count < (chunk_size as usize)
|
||||
&& attempt < max_attempts_per_chunk
|
||||
{
|
||||
// Даём TDLib время на синхронизацию с сервером
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Конвертируем сообщения (от новых к старым, потом реверсим)
|
||||
let mut chunk_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().flatten() {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
chunk_messages.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Реверсим чтобы получить порядок от старых к новым
|
||||
chunk_messages.reverse();
|
||||
|
||||
// Добавляем загруженные сообщения
|
||||
if !chunk_messages.is_empty() {
|
||||
// Для следующей итерации: ID самого старого сообщения из текущего чанка
|
||||
from_message_id = chunk_messages[0].id().as_i64();
|
||||
|
||||
// ВАЖНО: Вставляем чанк В НАЧАЛО списка!
|
||||
// Первый чанк содержит НОВЫЕ сообщения (например 51-100)
|
||||
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
|
||||
// Поэтому более старые чанки должны быть в начале списка
|
||||
if all_messages.is_empty() {
|
||||
// Первый чанк - просто добавляем
|
||||
all_messages = chunk_messages;
|
||||
} else {
|
||||
// Последующие чанки - вставляем в начало
|
||||
all_messages.splice(0..0, chunk_messages);
|
||||
}
|
||||
|
||||
chunk_loaded = true;
|
||||
}
|
||||
|
||||
// Если получили меньше чем chunk_size, значит это последний доступный чанк
|
||||
if (messages_obj.messages.len() as i32) < chunk_size {
|
||||
return Ok(all_messages);
|
||||
}
|
||||
|
||||
break; // Чанк успешно загружен
|
||||
}
|
||||
|
||||
// Если чанк не загрузился после всех попыток - прерываем
|
||||
if !chunk_loaded {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_messages)
|
||||
}
|
||||
|
||||
/// Загружает более старые сообщения для пагинации.
|
||||
///
|
||||
/// Используется для подгрузки предыдущих сообщений при прокрутке
|
||||
/// истории чата вверх.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `from_message_id` - ID сообщения, от которого загружать историю
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список старых сообщений (от старых к новым)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Загрузить сообщения старше указанного
|
||||
/// let older = msg_manager.load_older_messages(
|
||||
/// chat_id,
|
||||
/// MessageId::new(12345)
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
from_message_id.as_i64(),
|
||||
0, // offset
|
||||
TDLIB_MESSAGE_LIMIT,
|
||||
false,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
||||
let mut messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev().flatten() {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
messages.push(info);
|
||||
}
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка загрузки старых сообщений: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Получает все закрепленные сообщения чата.
|
||||
///
|
||||
/// Выполняет поиск всех сообщений с фильтром "pinned" и возвращает их список.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Список закрепленных сообщений (до 100)
|
||||
/// * `Err(String)` - Ошибка загрузки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
|
||||
/// println!("Found {} pinned messages", pinned.len());
|
||||
/// ```
|
||||
pub async fn get_pinned_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
String::new(),
|
||||
None,
|
||||
0, // from_message_id
|
||||
0, // offset
|
||||
100, // limit
|
||||
Some(SearchMessagesFilter::Pinned),
|
||||
0, // message_thread_id
|
||||
0, // saved_messages_topic_id
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut pinned_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
pinned_messages.push(info);
|
||||
}
|
||||
}
|
||||
Ok(pinned_messages)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Загружает текущее верхнее закрепленное сообщение.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
///
|
||||
/// # Compatibility
|
||||
///
|
||||
/// The current `tdlib-rs` schema no longer exposes `Chat.pinned_message_id`, and the
|
||||
/// generated wrapper does not provide `getChatPinnedMessage`. The pinned-message modal
|
||||
/// uses `get_pinned_messages` with `SearchMessagesFilter::Pinned`; this method keeps the
|
||||
/// legacy single-header state empty until TDLib exposes a direct top-pinned-message API.
|
||||
pub async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {
|
||||
self.current_pinned_message = None;
|
||||
}
|
||||
|
||||
/// Выполняет поиск сообщений по тексту в указанном чате.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата для поиска
|
||||
/// * `query` - Текстовый запрос для поиска
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<MessageInfo>)` - Найденные сообщения (до 100)
|
||||
/// * `Err(String)` - Ошибка поиска
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let results = msg_manager.search_messages(chat_id, "hello").await?;
|
||||
/// ```
|
||||
pub async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
query.to_string(),
|
||||
None,
|
||||
0, // from_message_id
|
||||
0, // offset
|
||||
100, // limit
|
||||
None,
|
||||
0, // message_thread_id
|
||||
0, // saved_messages_topic_id
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut search_results = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
search_results.push(info);
|
||||
}
|
||||
}
|
||||
Ok(search_results)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка поиска: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправляет текстовое сообщение в чат с поддержкой Markdown.
|
||||
///
|
||||
/// Автоматически парсит Markdown v2 форматирование (**bold**, *italic*, `code` и т.д.).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата-получателя
|
||||
/// * `text` - Текст сообщения (поддерживает Markdown v2)
|
||||
/// * `reply_to_message_id` - Опциональный ID сообщения для ответа
|
||||
/// * `reply_info` - Опциональная информация об исходном сообщении
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(MessageInfo)` - Отправленное сообщение
|
||||
/// * `Err(String)` - Ошибка отправки
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Простое сообщение
|
||||
/// let msg = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Hello, **world**!".to_string(),
|
||||
/// None,
|
||||
/// None
|
||||
/// ).await?;
|
||||
///
|
||||
/// // Ответ на сообщение
|
||||
/// let reply = msg_manager.send_message(
|
||||
/// chat_id,
|
||||
/// "Got it!".to_string(),
|
||||
/// Some(MessageId::new(123)),
|
||||
/// Some(reply_info)
|
||||
/// ).await?;
|
||||
/// ```
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<MessageInfo, String> {
|
||||
// Парсим markdown в тексте
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||
self.client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||
FormattedText { text: ft.text, entities: ft.entities }
|
||||
}
|
||||
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||
};
|
||||
|
||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: formatted_text,
|
||||
link_preview_options: None,
|
||||
clear_draft: true,
|
||||
});
|
||||
|
||||
let reply_to = reply_to_message_id.map(|msg_id| {
|
||||
InputMessageReplyTo::Message(InputMessageReplyToMessage {
|
||||
chat_id: 0,
|
||||
message_id: msg_id.as_i64(),
|
||||
quote: None,
|
||||
})
|
||||
});
|
||||
|
||||
let result = functions::send_message(
|
||||
chat_id.as_i64(),
|
||||
0, // message_thread_id
|
||||
reply_to,
|
||||
None, // options
|
||||
content,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||
let mut msg_info = self
|
||||
.convert_message(&msg, last_read_outbox_message_id)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
|
||||
|
||||
// Добавляем reply_info если был передан
|
||||
if let Some(reply) = reply_info {
|
||||
msg_info.interactions.reply_to = Some(reply);
|
||||
}
|
||||
|
||||
Ok(msg_info)
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Редактирует существующее сообщение.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_id` - ID сообщения для редактирования
|
||||
/// * `text` - Новый текст (поддерживает Markdown v2)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(MessageInfo)` - Отредактированное сообщение
|
||||
/// * `Err(String)` - Ошибка (нет прав, сообщение слишком старое и т.д.)
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||
self.client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
|
||||
FormattedText { text: ft.text, entities: ft.entities }
|
||||
}
|
||||
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
|
||||
};
|
||||
|
||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: formatted_text,
|
||||
link_preview_options: None,
|
||||
clear_draft: true,
|
||||
});
|
||||
|
||||
let result = functions::edit_message_text(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
content,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => self
|
||||
.convert_message(&msg, last_read_outbox_message_id)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
|
||||
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Удаляет одно или несколько сообщений.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_ids` - Список ID сообщений для удаления
|
||||
/// * `revoke` - `true` - удалить для всех, `false` - только для себя
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Сообщения удалены
|
||||
/// * `Err(String)` - Ошибка удаления
|
||||
pub async fn delete_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||
let result =
|
||||
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Пересылает сообщения из одного чата в другой.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `to_chat_id` - ID чата-получателя
|
||||
/// * `from_chat_id` - ID чата-источника
|
||||
/// * `message_ids` - Список ID сообщений для пересылки
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Сообщения переслань
|
||||
/// * `Err(String)` - Ошибка пересылки
|
||||
pub async fn forward_messages(
|
||||
&self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
|
||||
let result = functions::forward_messages(
|
||||
to_chat_id.as_i64(),
|
||||
0, // message_thread_id
|
||||
from_chat_id.as_i64(),
|
||||
message_ids_i64,
|
||||
None, // options
|
||||
false, // send_copy
|
||||
false, // remove_caption
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка пересылки: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохраняет черновик сообщения для чата.
|
||||
///
|
||||
/// Черновик отображается в списке чатов и восстанавливается
|
||||
/// при следующем открытии чата.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `text` - Текст черновика (пустая строка удаляет черновик)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Черновик сохранен
|
||||
/// * `Err(String)` - Ошибка сохранения
|
||||
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
use tdlib_rs::types::DraftMessage;
|
||||
|
||||
let draft = if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(DraftMessage {
|
||||
reply_to: None,
|
||||
date: 0,
|
||||
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
|
||||
text: FormattedText { text: text.clone(), entities: vec![] },
|
||||
link_preview_options: None,
|
||||
clear_draft: false,
|
||||
}),
|
||||
})
|
||||
};
|
||||
|
||||
let result =
|
||||
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка сохранения черновика: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает очередь сообщений для отметки как прочитанных.
|
||||
///
|
||||
/// Автоматически отмечает просмотренные сообщения как прочитанные,
|
||||
/// что сбрасывает счетчик непрочитанных сообщений в чате.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте периодически (например, в основном цикле) для обработки накопленной очереди.
|
||||
pub async fn process_pending_view_messages(&mut self) {
|
||||
if self.pending_view_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let batch = std::mem::take(&mut self.pending_view_messages);
|
||||
|
||||
for (chat_id, message_ids) in batch {
|
||||
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||
let _ =
|
||||
functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
crates/tele-core/src/tdlib/mod.rs
Normal file
37
crates/tele-core/src/tdlib/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
// Модули
|
||||
pub mod auth;
|
||||
mod chat_helpers; // Chat management helpers
|
||||
pub mod chats;
|
||||
pub mod client;
|
||||
mod client_impl; // Private module for trait implementation
|
||||
pub mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||
mod message_converter; // Message conversion utilities (for client.rs)
|
||||
pub mod messages;
|
||||
pub mod reactions;
|
||||
pub mod r#trait;
|
||||
pub mod types;
|
||||
mod update_handlers; // Update handlers extracted from client
|
||||
pub mod users;
|
||||
|
||||
// Экспорт основных типов
|
||||
pub use auth::AuthState;
|
||||
pub use client::TdClient;
|
||||
#[allow(unused_imports)]
|
||||
pub use r#trait::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{
|
||||
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
||||
VoiceDownloadState, VoiceInfo,
|
||||
};
|
||||
|
||||
pub use client::{IncomingMessageEvent, TdClientConfig, TdCredentials};
|
||||
#[cfg(feature = "images")]
|
||||
pub use types::ImageModalState;
|
||||
pub use users::UserCache;
|
||||
|
||||
// Re-export ChatAction для удобства
|
||||
pub use tdlib_rs::enums::ChatAction;
|
||||
230
crates/tele-core/src/tdlib/reactions.rs
Normal file
230
crates/tele-core/src/tdlib/reactions.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::enums::{AvailableReactions, ReactionType};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::{AvailableReaction, ReactionTypeEmoji};
|
||||
|
||||
/// Менеджер реакций на сообщения.
|
||||
///
|
||||
/// Управляет добавлением, удалением и получением списка доступных
|
||||
/// реакций (emoji) для сообщений в чатах.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let reaction_manager = ReactionManager::new(client_id);
|
||||
///
|
||||
/// // Получить доступные реакции
|
||||
/// let reactions = reaction_manager.get_message_available_reactions(
|
||||
/// chat_id,
|
||||
/// message_id
|
||||
/// ).await?;
|
||||
///
|
||||
/// // Добавить/удалить реакцию
|
||||
/// reaction_manager.toggle_reaction(chat_id, message_id, "👍".to_string()).await?;
|
||||
/// ```
|
||||
pub struct ReactionManager {
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
impl ReactionManager {
|
||||
/// Создает новый менеджер реакций.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self { client_id }
|
||||
}
|
||||
|
||||
/// Получает список доступных реакций для сообщения.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_id` - ID сообщения
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(Vec<String>)` - Список доступных emoji реакций
|
||||
/// * `Err(String)` - Ошибка получения
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let reactions = manager.get_message_available_reactions(
|
||||
/// ChatId::new(123),
|
||||
/// MessageId::new(456)
|
||||
/// ).await?;
|
||||
/// println!("Available: {:?}", reactions);
|
||||
/// ```
|
||||
pub async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
// Получаем сообщение
|
||||
let msg_result =
|
||||
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
|
||||
let _msg = match msg_result {
|
||||
Ok(m) => m,
|
||||
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),
|
||||
};
|
||||
|
||||
// Получаем доступные реакции для чата
|
||||
let reactions_result = functions::get_message_available_reactions(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
10, // row_size
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match reactions_result {
|
||||
Ok(available) => {
|
||||
let emojis = available_reaction_emojis(&available);
|
||||
if emojis.is_empty() {
|
||||
Ok(default_reaction_emojis())
|
||||
} else {
|
||||
Ok(emojis)
|
||||
}
|
||||
}
|
||||
Err(_) => Ok(default_reaction_emojis()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Переключает реакцию на сообщение (добавляет/удаляет).
|
||||
///
|
||||
/// Сначала пытается добавить реакцию. Если не удалось (уже есть),
|
||||
/// то удаляет её.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `chat_id` - ID чата
|
||||
/// * `message_id` - ID сообщения
|
||||
/// * `emoji` - Emoji реакции (например, "👍", "❤️")
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` - Реакция переключена
|
||||
/// * `Err(String)` - Ошибка переключения
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// // Добавить или удалить 👍
|
||||
/// manager.toggle_reaction(chat_id, message_id, "👍".to_string()).await?;
|
||||
/// ```
|
||||
pub async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
emoji: String,
|
||||
) -> Result<(), String> {
|
||||
let reaction = ReactionType::Emoji(ReactionTypeEmoji { emoji });
|
||||
|
||||
let result = functions::add_message_reaction(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
reaction.clone(),
|
||||
false, // is_big
|
||||
false, // update_recent_reactions
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => {
|
||||
// Если добавление не удалось, пытаемся удалить
|
||||
let remove_result = functions::remove_message_reaction(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
reaction,
|
||||
self.client_id,
|
||||
)
|
||||
.await;
|
||||
match remove_result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => Err(format!("Ошибка переключения реакции: {:?}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_reaction_emojis() -> Vec<String> {
|
||||
vec![
|
||||
"👍".to_string(),
|
||||
"👎".to_string(),
|
||||
"❤️".to_string(),
|
||||
"🔥".to_string(),
|
||||
"😊".to_string(),
|
||||
"😢".to_string(),
|
||||
"😮".to_string(),
|
||||
"🎉".to_string(),
|
||||
"🤔".to_string(),
|
||||
"😡".to_string(),
|
||||
"😎".to_string(),
|
||||
"🤝".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn available_reaction_emojis(available: &AvailableReactions) -> Vec<String> {
|
||||
let AvailableReactions::AvailableReactions(available) = available;
|
||||
|
||||
available
|
||||
.top_reactions
|
||||
.iter()
|
||||
.chain(available.recent_reactions.iter())
|
||||
.chain(available.popular_reactions.iter())
|
||||
.filter_map(reaction_emoji)
|
||||
.fold(Vec::new(), |mut emojis, emoji| {
|
||||
if !emojis.contains(&emoji) {
|
||||
emojis.push(emoji);
|
||||
}
|
||||
emojis
|
||||
})
|
||||
}
|
||||
|
||||
fn reaction_emoji(reaction: &AvailableReaction) -> Option<String> {
|
||||
match &reaction.r#type {
|
||||
ReactionType::Emoji(emoji) => Some(emoji.emoji.clone()),
|
||||
ReactionType::CustomEmoji(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tdlib_rs::types::{AvailableReaction, AvailableReactions as AvailableReactionsData};
|
||||
|
||||
fn emoji_reaction(emoji: &str) -> AvailableReaction {
|
||||
AvailableReaction {
|
||||
r#type: ReactionType::Emoji(ReactionTypeEmoji { emoji: emoji.to_string() }),
|
||||
needs_premium: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_unique_emoji_reactions_in_display_order() {
|
||||
let available = AvailableReactions::AvailableReactions(AvailableReactionsData {
|
||||
top_reactions: vec![emoji_reaction("👍"), emoji_reaction("🔥")],
|
||||
recent_reactions: vec![emoji_reaction("🔥"), emoji_reaction("❤️")],
|
||||
popular_reactions: vec![emoji_reaction("🎉")],
|
||||
allow_custom_emoji: false,
|
||||
are_tags: false,
|
||||
unavailability_reason: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
available_reaction_emojis(&available),
|
||||
vec![
|
||||
"👍".to_string(),
|
||||
"🔥".to_string(),
|
||||
"❤️".to_string(),
|
||||
"🎉".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
218
crates/tele-core/src/tdlib/trait.rs
Normal file
218
crates/tele-core/src/tdlib/trait.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! Trait definition for TdClient to enable dependency injection
|
||||
//!
|
||||
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::tdlib::{
|
||||
AuthState, FolderInfo, IncomingMessageEvent, MessageInfo, ProfileInfo, UserCache,
|
||||
UserOnlineStatus,
|
||||
};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
use super::ChatInfo;
|
||||
|
||||
/// Auth operations.
|
||||
#[async_trait]
|
||||
pub trait AuthClient: Send {
|
||||
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
|
||||
async fn send_code(&self, code: String) -> Result<(), String>;
|
||||
async fn send_password(&self, password: String) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Chat list and profile operations.
|
||||
#[async_trait]
|
||||
pub trait ChatClient: Send {
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String>;
|
||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>;
|
||||
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
|
||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
|
||||
|
||||
fn chats(&self) -> &[ChatInfo];
|
||||
fn folders(&self) -> &[FolderInfo];
|
||||
fn main_chat_list_position(&self) -> i32;
|
||||
fn set_main_chat_list_position(&mut self, position: i32);
|
||||
fn update_chats<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>);
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>);
|
||||
}
|
||||
|
||||
/// Ephemeral chat actions such as typing status.
|
||||
#[async_trait]
|
||||
pub trait ChatActionClient: Send {
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
|
||||
fn clear_stale_typing_status(&mut self) -> bool;
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
|
||||
}
|
||||
|
||||
/// Message history, search, and mutation operations.
|
||||
#[async_trait]
|
||||
pub trait MessageClient: Send {
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String>;
|
||||
async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String>;
|
||||
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
|
||||
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
|
||||
async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String>;
|
||||
|
||||
async fn send_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<super::ReplyInfo>,
|
||||
) -> Result<MessageInfo, String>;
|
||||
|
||||
async fn edit_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String>;
|
||||
|
||||
async fn delete_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String>;
|
||||
|
||||
async fn forward_messages(
|
||||
&mut self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String>;
|
||||
|
||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>;
|
||||
|
||||
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]>;
|
||||
fn current_chat_id(&self) -> Option<ChatId>;
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||
fn push_message(&mut self, msg: MessageInfo);
|
||||
fn clear_current_chat_messages(&mut self);
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>);
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>);
|
||||
async fn fetch_missing_reply_info(&mut self);
|
||||
async fn process_pending_view_messages(&mut self);
|
||||
}
|
||||
|
||||
/// User cache and user-status operations.
|
||||
#[async_trait]
|
||||
pub trait UserClient: Send {
|
||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>;
|
||||
fn pending_user_ids(&self) -> &[UserId];
|
||||
fn user_cache(&self) -> &UserCache;
|
||||
fn update_user_cache<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut UserCache);
|
||||
async fn process_pending_user_ids(&mut self);
|
||||
}
|
||||
|
||||
/// Message reaction operations.
|
||||
#[async_trait]
|
||||
pub trait ReactionClient: Send {
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String>;
|
||||
|
||||
async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reaction: String,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// File download operations.
|
||||
#[async_trait]
|
||||
pub trait FileClient: Send {
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
||||
}
|
||||
|
||||
/// Shared client state that does not belong to one feature area.
|
||||
#[async_trait]
|
||||
pub trait ClientState: Send {
|
||||
fn client_id(&self) -> i32;
|
||||
async fn get_me(&self) -> Result<i64, String>;
|
||||
fn auth_state(&self) -> &AuthState;
|
||||
fn network_state(&self) -> super::types::NetworkState;
|
||||
}
|
||||
|
||||
/// Account switching operations.
|
||||
#[async_trait]
|
||||
pub trait AccountClient: Send {
|
||||
/// Recreates the client with a new database path (for account switching).
|
||||
///
|
||||
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
||||
/// For FakeTdClient: no-op.
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// TDLib update routing.
|
||||
pub trait UpdateClient: Send {
|
||||
fn handle_update(&mut self, update: Update);
|
||||
fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent>;
|
||||
}
|
||||
|
||||
/// Facade trait for TDLib client operations
|
||||
///
|
||||
/// This trait defines the interface for both real and fake TDLib clients,
|
||||
/// enabling dependency injection and easier testing.
|
||||
#[allow(dead_code)]
|
||||
pub trait TdClientTrait:
|
||||
AuthClient
|
||||
+ ChatClient
|
||||
+ ChatActionClient
|
||||
+ MessageClient
|
||||
+ UserClient
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
{
|
||||
}
|
||||
|
||||
impl<T> TdClientTrait for T where
|
||||
T: AuthClient
|
||||
+ ChatClient
|
||||
+ ChatActionClient
|
||||
+ MessageClient
|
||||
+ UserClient
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
{
|
||||
}
|
||||
724
crates/tele-core/src/tdlib/types.rs
Normal file
724
crates/tele-core/src/tdlib/types.rs
Normal file
@@ -0,0 +1,724 @@
|
||||
use tdlib_rs::enums::TextEntityType;
|
||||
use tdlib_rs::types::TextEntity;
|
||||
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatInfo {
|
||||
pub id: ChatId,
|
||||
pub title: String,
|
||||
pub username: Option<String>,
|
||||
pub last_message: String,
|
||||
pub last_message_date: i32,
|
||||
pub unread_count: i32,
|
||||
/// Количество непрочитанных упоминаний (@)
|
||||
pub unread_mention_count: i32,
|
||||
pub is_pinned: bool,
|
||||
pub order: i64,
|
||||
/// ID последнего прочитанного исходящего сообщения (для галочек)
|
||||
pub last_read_outbox_message_id: MessageId,
|
||||
/// ID папок, в которых находится чат
|
||||
pub folder_ids: Vec<i32>,
|
||||
/// Чат замьючен (уведомления отключены)
|
||||
pub is_muted: bool,
|
||||
/// Черновик сообщения
|
||||
pub draft_text: Option<String>,
|
||||
}
|
||||
|
||||
/// Информация о сообщении, на которое отвечают
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReplyInfo {
|
||||
/// ID сообщения, на которое отвечают
|
||||
pub message_id: MessageId,
|
||||
/// Имя отправителя оригинального сообщения
|
||||
pub sender_name: String,
|
||||
/// Текст оригинального сообщения (превью)
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Информация о пересланном сообщении
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForwardInfo {
|
||||
/// Имя оригинального отправителя
|
||||
pub sender_name: String,
|
||||
}
|
||||
|
||||
/// Информация о реакции на сообщение
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactionInfo {
|
||||
/// Эмодзи реакции (например, "👍")
|
||||
pub emoji: String,
|
||||
/// Количество людей, поставивших эту реакцию
|
||||
pub count: i32,
|
||||
/// Поставил ли текущий пользователь эту реакцию
|
||||
pub is_chosen: bool,
|
||||
}
|
||||
|
||||
/// Информация о медиа-контенте сообщения
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MediaInfo {
|
||||
Photo(PhotoInfo),
|
||||
Voice(VoiceInfo),
|
||||
}
|
||||
|
||||
/// Информация о фотографии в сообщении
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PhotoInfo {
|
||||
pub file_id: i32,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub download_state: PhotoDownloadState,
|
||||
}
|
||||
|
||||
/// Состояние загрузки фотографии
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PhotoDownloadState {
|
||||
NotDownloaded,
|
||||
Downloading,
|
||||
Downloaded(String),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Информация о голосовом сообщении
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VoiceInfo {
|
||||
pub file_id: i32,
|
||||
pub duration: i32, // seconds
|
||||
pub mime_type: String,
|
||||
/// Waveform данные для визуализации (base64-encoded строка амплитуд)
|
||||
pub waveform: String,
|
||||
pub download_state: VoiceDownloadState,
|
||||
}
|
||||
|
||||
/// Состояние загрузки голосового сообщения
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum VoiceDownloadState {
|
||||
NotDownloaded,
|
||||
Downloading,
|
||||
Downloaded(String), // path to cached OGG file
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Метаданные сообщения (ID, отправитель, время)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageMetadata {
|
||||
pub id: MessageId,
|
||||
pub sender_name: String,
|
||||
pub date: i32,
|
||||
/// Дата редактирования (0 если не редактировалось)
|
||||
pub edit_date: i32,
|
||||
/// ID медиа-альбома (0 если не часть альбома)
|
||||
pub media_album_id: i64,
|
||||
}
|
||||
|
||||
/// Контент сообщения (текст и форматирование)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageContent {
|
||||
pub text: String,
|
||||
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||
pub entities: Vec<TextEntity>,
|
||||
/// Медиа-контент (фото, видео и т.д.)
|
||||
pub media: Option<MediaInfo>,
|
||||
}
|
||||
|
||||
/// Состояние и права доступа к сообщению
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageState {
|
||||
pub is_outgoing: bool,
|
||||
pub is_read: bool,
|
||||
/// Можно ли редактировать сообщение
|
||||
pub can_be_edited: bool,
|
||||
/// Можно ли удалить только для себя
|
||||
pub can_be_deleted_only_for_self: bool,
|
||||
/// Можно ли удалить для всех
|
||||
pub can_be_deleted_for_all_users: bool,
|
||||
}
|
||||
|
||||
/// Взаимодействия с сообщением (reply, forward, reactions)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageInteractions {
|
||||
/// Информация о reply (если это ответ на сообщение)
|
||||
pub reply_to: Option<ReplyInfo>,
|
||||
/// Информация о forward (если сообщение переслано)
|
||||
pub forward_from: Option<ForwardInfo>,
|
||||
/// Реакции на сообщение
|
||||
pub reactions: Vec<ReactionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageInfo {
|
||||
pub metadata: MessageMetadata,
|
||||
pub content: MessageContent,
|
||||
pub state: MessageState,
|
||||
pub interactions: MessageInteractions,
|
||||
}
|
||||
|
||||
impl MessageInfo {
|
||||
/// Создать новое сообщение
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: MessageId,
|
||||
sender_name: String,
|
||||
is_outgoing: bool,
|
||||
content: String,
|
||||
entities: Vec<TextEntity>,
|
||||
date: i32,
|
||||
edit_date: i32,
|
||||
is_read: bool,
|
||||
can_be_edited: bool,
|
||||
can_be_deleted_only_for_self: bool,
|
||||
can_be_deleted_for_all_users: bool,
|
||||
reply_to: Option<ReplyInfo>,
|
||||
forward_from: Option<ForwardInfo>,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
metadata: MessageMetadata {
|
||||
id,
|
||||
sender_name,
|
||||
date,
|
||||
edit_date,
|
||||
media_album_id: 0,
|
||||
},
|
||||
content: MessageContent { text: content, entities, media: None },
|
||||
state: MessageState {
|
||||
is_outgoing,
|
||||
is_read,
|
||||
can_be_edited,
|
||||
can_be_deleted_only_for_self,
|
||||
can_be_deleted_for_all_users,
|
||||
},
|
||||
interactions: MessageInteractions { reply_to, forward_from, reactions },
|
||||
}
|
||||
}
|
||||
|
||||
// Удобные getter'ы для частых операций
|
||||
pub fn id(&self) -> MessageId {
|
||||
self.metadata.id
|
||||
}
|
||||
|
||||
pub fn sender_name(&self) -> &str {
|
||||
&self.metadata.sender_name
|
||||
}
|
||||
|
||||
pub fn date(&self) -> i32 {
|
||||
self.metadata.date
|
||||
}
|
||||
|
||||
pub fn is_edited(&self) -> bool {
|
||||
self.metadata.edit_date > 0
|
||||
}
|
||||
|
||||
pub fn media_album_id(&self) -> i64 {
|
||||
self.metadata.media_album_id
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.content.text
|
||||
}
|
||||
|
||||
pub fn entities(&self) -> &[TextEntity] {
|
||||
&self.content.entities
|
||||
}
|
||||
|
||||
pub fn is_outgoing(&self) -> bool {
|
||||
self.state.is_outgoing
|
||||
}
|
||||
|
||||
pub fn is_read(&self) -> bool {
|
||||
self.state.is_read
|
||||
}
|
||||
|
||||
pub fn can_be_edited(&self) -> bool {
|
||||
self.state.can_be_edited
|
||||
}
|
||||
|
||||
pub fn can_be_deleted_only_for_self(&self) -> bool {
|
||||
self.state.can_be_deleted_only_for_self
|
||||
}
|
||||
|
||||
pub fn can_be_deleted_for_all_users(&self) -> bool {
|
||||
self.state.can_be_deleted_for_all_users
|
||||
}
|
||||
|
||||
/// Checks if the message contains a mention (@username or user mention)
|
||||
pub fn has_mention(&self) -> bool {
|
||||
self.content.entities.iter().any(|entity| {
|
||||
matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
|
||||
})
|
||||
}
|
||||
|
||||
/// Проверяет, содержит ли сообщение фото
|
||||
pub fn has_photo(&self) -> bool {
|
||||
matches!(self.content.media, Some(MediaInfo::Photo(_)))
|
||||
}
|
||||
|
||||
/// Возвращает ссылку на PhotoInfo (если есть)
|
||||
pub fn photo_info(&self) -> Option<&PhotoInfo> {
|
||||
match &self.content.media {
|
||||
Some(MediaInfo::Photo(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает мутабельную ссылку на PhotoInfo (если есть)
|
||||
pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> {
|
||||
match &mut self.content.media {
|
||||
Some(MediaInfo::Photo(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, содержит ли сообщение голосовое
|
||||
pub fn has_voice(&self) -> bool {
|
||||
matches!(self.content.media, Some(MediaInfo::Voice(_)))
|
||||
}
|
||||
|
||||
/// Возвращает ссылку на VoiceInfo (если есть)
|
||||
pub fn voice_info(&self) -> Option<&VoiceInfo> {
|
||||
match &self.content.media {
|
||||
Some(MediaInfo::Voice(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает мутабельную ссылку на VoiceInfo (если есть)
|
||||
#[allow(dead_code)]
|
||||
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
|
||||
match &mut self.content.media {
|
||||
Some(MediaInfo::Voice(info)) => Some(info),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
||||
self.interactions.reply_to.as_ref()
|
||||
}
|
||||
|
||||
pub fn forward_from(&self) -> Option<&ForwardInfo> {
|
||||
self.interactions.forward_from.as_ref()
|
||||
}
|
||||
|
||||
pub fn reactions(&self) -> &[ReactionInfo] {
|
||||
&self.interactions.reactions
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder для удобного создания MessageInfo с fluent API
|
||||
///
|
||||
/// # Примеры
|
||||
///
|
||||
/// ```
|
||||
/// use tele_core::tdlib::MessageBuilder;
|
||||
/// use tele_core::types::MessageId;
|
||||
///
|
||||
/// let message = MessageBuilder::new(MessageId::new(123))
|
||||
/// .sender_name("Alice")
|
||||
/// .text("Hello, world!")
|
||||
/// .outgoing()
|
||||
/// .date(1640000000)
|
||||
/// .build();
|
||||
/// ```
|
||||
pub struct MessageBuilder {
|
||||
id: MessageId,
|
||||
sender_name: String,
|
||||
is_outgoing: bool,
|
||||
text: String,
|
||||
entities: Vec<TextEntity>,
|
||||
date: i32,
|
||||
edit_date: i32,
|
||||
is_read: bool,
|
||||
can_be_edited: bool,
|
||||
can_be_deleted_only_for_self: bool,
|
||||
can_be_deleted_for_all_users: bool,
|
||||
reply_to: Option<ReplyInfo>,
|
||||
forward_from: Option<ForwardInfo>,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
media: Option<MediaInfo>,
|
||||
media_album_id: i64,
|
||||
}
|
||||
|
||||
impl MessageBuilder {
|
||||
/// Создать новый builder с обязательным ID сообщения
|
||||
pub fn new(id: MessageId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
sender_name: String::new(),
|
||||
is_outgoing: false,
|
||||
text: String::new(),
|
||||
entities: Vec::new(),
|
||||
date: 0,
|
||||
edit_date: 0,
|
||||
is_read: false,
|
||||
can_be_edited: false,
|
||||
can_be_deleted_only_for_self: true,
|
||||
can_be_deleted_for_all_users: false,
|
||||
reply_to: None,
|
||||
forward_from: None,
|
||||
reactions: Vec::new(),
|
||||
media: None,
|
||||
media_album_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Установить имя отправителя
|
||||
pub fn sender_name(mut self, name: impl Into<String>) -> Self {
|
||||
self.sender_name = name.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Пометить сообщение как исходящее
|
||||
pub fn outgoing(mut self) -> Self {
|
||||
self.is_outgoing = true;
|
||||
self.can_be_edited = true;
|
||||
self.can_be_deleted_for_all_users = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Пометить сообщение как входящее
|
||||
pub fn incoming(mut self) -> Self {
|
||||
self.is_outgoing = false;
|
||||
self.can_be_edited = false;
|
||||
self.can_be_deleted_for_all_users = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить текст сообщения
|
||||
pub fn text(mut self, text: impl Into<String>) -> Self {
|
||||
self.text = text.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить entities для форматирования
|
||||
pub fn entities(mut self, entities: Vec<TextEntity>) -> Self {
|
||||
self.entities = entities;
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить дату сообщения (unix timestamp)
|
||||
pub fn date(mut self, date: i32) -> Self {
|
||||
self.date = date;
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить дату редактирования (unix timestamp)
|
||||
pub fn edit_date(mut self, edit_date: i32) -> Self {
|
||||
self.edit_date = edit_date;
|
||||
self
|
||||
}
|
||||
|
||||
/// Пометить сообщение как прочитанное
|
||||
pub fn read(mut self) -> Self {
|
||||
self.is_read = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Пометить сообщение как непрочитанное
|
||||
pub fn unread(mut self) -> Self {
|
||||
self.is_read = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// Разрешить редактирование
|
||||
pub fn editable(mut self) -> Self {
|
||||
self.can_be_edited = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Разрешить удаление только для себя
|
||||
pub fn deletable_for_self(mut self) -> Self {
|
||||
self.can_be_deleted_only_for_self = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Разрешить удаление для всех
|
||||
pub fn deletable_for_all(mut self) -> Self {
|
||||
self.can_be_deleted_for_all_users = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить информацию об ответе
|
||||
pub fn reply_to(mut self, reply: ReplyInfo) -> Self {
|
||||
self.reply_to = Some(reply);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить информацию о пересылке
|
||||
pub fn forward_from(mut self, forward: ForwardInfo) -> Self {
|
||||
self.forward_from = Some(forward);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить реакции
|
||||
pub fn reactions(mut self, reactions: Vec<ReactionInfo>) -> Self {
|
||||
self.reactions = reactions;
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить медиа-контент
|
||||
pub fn media(mut self, media: MediaInfo) -> Self {
|
||||
self.media = Some(media);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить ID медиа-альбома
|
||||
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||
self.media_album_id = id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Построить MessageInfo из данных builder'а
|
||||
pub fn build(self) -> MessageInfo {
|
||||
let mut msg = MessageInfo::new(
|
||||
self.id,
|
||||
self.sender_name,
|
||||
self.is_outgoing,
|
||||
self.text,
|
||||
self.entities,
|
||||
self.date,
|
||||
self.edit_date,
|
||||
self.is_read,
|
||||
self.can_be_edited,
|
||||
self.can_be_deleted_only_for_self,
|
||||
self.can_be_deleted_for_all_users,
|
||||
self.reply_to,
|
||||
self.forward_from,
|
||||
self.reactions,
|
||||
);
|
||||
msg.content.media = self.media;
|
||||
msg.metadata.media_album_id = self.media_album_id;
|
||||
msg
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::MessageId;
|
||||
|
||||
#[test]
|
||||
fn test_message_builder_basic() {
|
||||
let message = MessageBuilder::new(MessageId::new(123))
|
||||
.sender_name("Alice")
|
||||
.text("Hello, world!")
|
||||
.date(1640000000)
|
||||
.build();
|
||||
|
||||
assert_eq!(message.id(), MessageId::new(123));
|
||||
assert_eq!(message.sender_name(), "Alice");
|
||||
assert_eq!(message.text(), "Hello, world!");
|
||||
assert_eq!(message.date(), 1640000000);
|
||||
assert!(!message.is_outgoing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_builder_outgoing() {
|
||||
let message = MessageBuilder::new(MessageId::new(456))
|
||||
.sender_name("Me")
|
||||
.text("Test message")
|
||||
.outgoing()
|
||||
.read()
|
||||
.build();
|
||||
|
||||
assert!(message.is_outgoing());
|
||||
assert!(message.is_read());
|
||||
assert!(message.can_be_edited());
|
||||
assert!(message.can_be_deleted_for_all_users());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_builder_edited() {
|
||||
let message = MessageBuilder::new(MessageId::new(789))
|
||||
.text("Original text")
|
||||
.date(1640000000)
|
||||
.edit_date(1640000060)
|
||||
.build();
|
||||
|
||||
assert!(message.is_edited());
|
||||
assert_eq!(message.metadata.edit_date, 1640000060);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_builder_with_reply() {
|
||||
let reply = ReplyInfo {
|
||||
message_id: MessageId::new(100),
|
||||
sender_name: "Bob".to_string(),
|
||||
text: "Original message".to_string(),
|
||||
};
|
||||
|
||||
let message = MessageBuilder::new(MessageId::new(200))
|
||||
.text("Reply text")
|
||||
.reply_to(reply)
|
||||
.build();
|
||||
|
||||
assert!(message.reply_to().is_some());
|
||||
assert_eq!(message.reply_to().unwrap().sender_name, "Bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_builder_with_reactions() {
|
||||
let reaction = ReactionInfo {
|
||||
emoji: "👍".to_string(), count: 5, is_chosen: true
|
||||
};
|
||||
|
||||
let message = MessageBuilder::new(MessageId::new(300))
|
||||
.text("Cool message")
|
||||
.reactions(vec![reaction.clone()])
|
||||
.build();
|
||||
|
||||
assert_eq!(message.reactions().len(), 1);
|
||||
assert_eq!(message.reactions()[0].emoji, "👍");
|
||||
assert_eq!(message.reactions()[0].count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_builder_fluent_api() {
|
||||
let message = MessageBuilder::new(MessageId::new(999))
|
||||
.sender_name("Charlie")
|
||||
.text("Complex message")
|
||||
.date(1640000000)
|
||||
.outgoing()
|
||||
.read()
|
||||
.editable()
|
||||
.deletable_for_all()
|
||||
.build();
|
||||
|
||||
assert_eq!(message.sender_name(), "Charlie");
|
||||
assert_eq!(message.text(), "Complex message");
|
||||
assert!(message.is_outgoing());
|
||||
assert!(message.is_read());
|
||||
assert!(message.can_be_edited());
|
||||
assert!(message.can_be_deleted_for_all_users());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_has_mention() {
|
||||
// Message without mentions
|
||||
let message = MessageBuilder::new(MessageId::new(1))
|
||||
.text("Hello world")
|
||||
.build();
|
||||
assert!(!message.has_mention());
|
||||
|
||||
// Message with @mention
|
||||
let message_with_mention = MessageBuilder::new(MessageId::new(2))
|
||||
.text("Hello @user")
|
||||
.entities(vec![TextEntity {
|
||||
offset: 6,
|
||||
length: 5,
|
||||
r#type: TextEntityType::Mention,
|
||||
}])
|
||||
.build();
|
||||
assert!(message_with_mention.has_mention());
|
||||
|
||||
// Message with MentionName
|
||||
let message_with_mention_name = MessageBuilder::new(MessageId::new(3))
|
||||
.text("Hello John")
|
||||
.entities(vec![TextEntity {
|
||||
offset: 6,
|
||||
length: 4,
|
||||
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
|
||||
user_id: 123,
|
||||
}),
|
||||
}])
|
||||
.build();
|
||||
assert!(message_with_mention_name.has_mention());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FolderInfo {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Информация о профиле чата/пользователя
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfileInfo {
|
||||
pub chat_id: ChatId,
|
||||
pub title: String,
|
||||
pub username: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub phone_number: Option<String>,
|
||||
pub chat_type: String, // "Личный чат", "Группа", "Канал"
|
||||
pub member_count: Option<i32>,
|
||||
pub description: Option<String>,
|
||||
pub invite_link: Option<String>,
|
||||
pub is_group: bool,
|
||||
pub online_status: Option<String>,
|
||||
}
|
||||
|
||||
/// Состояние сетевого соединения
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum NetworkState {
|
||||
/// Ожидание подключения к сети
|
||||
WaitingForNetwork,
|
||||
/// Подключение к прокси
|
||||
ConnectingToProxy,
|
||||
/// Подключение к серверам Telegram
|
||||
Connecting,
|
||||
/// Обновление данных
|
||||
Updating,
|
||||
/// Подключено
|
||||
Ready,
|
||||
}
|
||||
|
||||
/// Онлайн-статус пользователя
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum UserOnlineStatus {
|
||||
/// Онлайн
|
||||
Online,
|
||||
/// Был недавно (менее часа назад)
|
||||
Recently,
|
||||
/// Был на этой неделе
|
||||
LastWeek,
|
||||
/// Был в этом месяце
|
||||
LastMonth,
|
||||
/// Давно не был
|
||||
LongTimeAgo,
|
||||
/// Оффлайн с указанием времени (unix timestamp)
|
||||
Offline(i32),
|
||||
}
|
||||
|
||||
/// Состояние модального окна для просмотра изображения
|
||||
#[cfg(feature = "images")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageModalState {
|
||||
/// ID сообщения с фото
|
||||
pub message_id: MessageId,
|
||||
/// Путь к файлу изображения
|
||||
pub photo_path: String,
|
||||
/// Ширина оригинального изображения
|
||||
pub photo_width: i32,
|
||||
/// Высота оригинального изображения
|
||||
pub photo_height: i32,
|
||||
}
|
||||
|
||||
/// Состояние воспроизведения голосового сообщения
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlaybackState {
|
||||
/// ID сообщения, которое воспроизводится
|
||||
pub message_id: MessageId,
|
||||
/// Статус воспроизведения
|
||||
pub status: PlaybackStatus,
|
||||
/// Текущая позиция (секунды)
|
||||
pub position: f32,
|
||||
/// Общая длительность (секунды)
|
||||
pub duration: f32,
|
||||
/// Громкость (0.0 - 1.0)
|
||||
pub volume: f32,
|
||||
}
|
||||
|
||||
/// Статус воспроизведения
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PlaybackStatus {
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
Loading,
|
||||
Error(String),
|
||||
}
|
||||
324
crates/tele-core/src/tdlib/update_handlers.rs
Normal file
324
crates/tele-core/src/tdlib/update_handlers.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
//! Update handlers for TDLib events.
|
||||
//!
|
||||
//! This module contains functions that process various types of updates from TDLib.
|
||||
//! Each handler is responsible for updating the application state based on the received update.
|
||||
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use std::time::Instant;
|
||||
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
|
||||
use tdlib_rs::types::{
|
||||
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
|
||||
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
|
||||
};
|
||||
|
||||
use super::auth::AuthState;
|
||||
use super::client::TdClient;
|
||||
use super::types::ReactionInfo;
|
||||
|
||||
/// Обрабатывает Update::NewMessage - добавление нового сообщения
|
||||
pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) {
|
||||
let chat_id = ChatId::new(new_msg.message.chat_id);
|
||||
|
||||
// Если сообщение НЕ для текущего открытого чата - отправляем уведомление
|
||||
if Some(chat_id) != client.current_chat_id() {
|
||||
// Find and clone chat info to avoid borrow checker issues
|
||||
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
|
||||
let msg_info =
|
||||
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
|
||||
// Get sender name (from message or user cache)
|
||||
let sender_name = msg_info.sender_name().to_string();
|
||||
|
||||
client.enqueue_incoming_message_event(chat, msg_info, sender_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем новое сообщение если это текущий открытый чат
|
||||
|
||||
let msg_info =
|
||||
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
let msg_id = msg_info.id();
|
||||
let is_incoming = !msg_info.is_outgoing();
|
||||
|
||||
// Проверяем, есть ли уже сообщение с таким id
|
||||
let existing_idx = client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.position(|m| m.id() == msg_info.id());
|
||||
|
||||
match existing_idx {
|
||||
Some(idx) => {
|
||||
// Сообщение уже есть - обновляем
|
||||
if is_incoming {
|
||||
client.replace_current_chat_message(msg_id, msg_info);
|
||||
} else {
|
||||
// Для исходящих: обновляем can_be_edited и другие поля,
|
||||
// но сохраняем reply_to (добавленный при отправке)
|
||||
client.update_current_chat_messages(|messages| {
|
||||
let existing = &mut messages[idx];
|
||||
existing.state.can_be_edited = msg_info.state.can_be_edited;
|
||||
existing.state.can_be_deleted_only_for_self =
|
||||
msg_info.state.can_be_deleted_only_for_self;
|
||||
existing.state.can_be_deleted_for_all_users =
|
||||
msg_info.state.can_be_deleted_for_all_users;
|
||||
existing.state.is_read = msg_info.state.is_read;
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Нового сообщения нет - добавляем
|
||||
client.push_message(msg_info.clone());
|
||||
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
||||
if is_incoming {
|
||||
client.enqueue_pending_view_messages(chat_id, vec![msg_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::ChatAction - статус набора текста/отправки файлов
|
||||
pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction) {
|
||||
// Обрабатываем только для текущего открытого чата
|
||||
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлекаем user_id из sender_id
|
||||
let MessageSender::User(user) = update.sender_id else {
|
||||
return; // Игнорируем действия от имени чата
|
||||
};
|
||||
let user_id = UserId::new(user.user_id);
|
||||
|
||||
// Определяем текст действия
|
||||
let action_text = match update.action {
|
||||
ChatAction::Typing => Some("печатает...".to_string()),
|
||||
ChatAction::RecordingVideo => Some("записывает видео...".to_string()),
|
||||
ChatAction::UploadingVideo(_) => Some("отправляет видео...".to_string()),
|
||||
ChatAction::RecordingVoiceNote => Some("записывает голосовое...".to_string()),
|
||||
ChatAction::UploadingVoiceNote(_) => Some("отправляет голосовое...".to_string()),
|
||||
ChatAction::UploadingPhoto(_) => Some("отправляет фото...".to_string()),
|
||||
ChatAction::UploadingDocument(_) => Some("отправляет файл...".to_string()),
|
||||
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
|
||||
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
|
||||
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
|
||||
_ => None, // Отмена или неизвестное действие
|
||||
};
|
||||
|
||||
match action_text {
|
||||
Some(text) => client.set_typing_status(Some((user_id, text, Instant::now()))),
|
||||
None => client.set_typing_status(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::ChatPosition - изменение позиции чата в списке.
|
||||
///
|
||||
/// Обновляет order и is_pinned для чатов в Main списке,
|
||||
/// управляет folder_ids для чатов в папках.
|
||||
pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosition) {
|
||||
let chat_id = ChatId::new(update.chat_id);
|
||||
match &update.position.list {
|
||||
ChatList::Main => {
|
||||
if update.position.order == 0 {
|
||||
// Чат больше не в Main (перемещён в архив и т.д.)
|
||||
client.remove_chat(chat_id);
|
||||
} else {
|
||||
// Обновляем позицию существующего чата
|
||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||
chat.order = update.position.order;
|
||||
chat.is_pinned = update.position.is_pinned;
|
||||
});
|
||||
}
|
||||
// Пересортируем по order
|
||||
client.sort_chats_by_order();
|
||||
}
|
||||
ChatList::Folder(folder) => {
|
||||
// Обновляем folder_ids для чата
|
||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||
if update.position.order == 0 {
|
||||
// Чат удалён из папки
|
||||
chat.folder_ids.retain(|&id| id != folder.chat_folder_id);
|
||||
} else {
|
||||
// Чат добавлен в папку
|
||||
if !chat.folder_ids.contains(&folder.chat_folder_id) {
|
||||
chat.folder_ids.push(folder.chat_folder_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ChatList::Archive => {
|
||||
// Архив пока не обрабатываем
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::User - обновление информации о пользователе.
|
||||
///
|
||||
/// Сохраняет display name и username в кэше,
|
||||
/// обновляет username в связанных чатах,
|
||||
/// удаляет "Deleted Account" из списка чатов.
|
||||
pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
|
||||
let user = update.user;
|
||||
|
||||
// Пропускаем удалённые аккаунты (пустое имя)
|
||||
if user.first_name.is_empty() && user.last_name.is_empty() {
|
||||
// Удаляем чаты с этим пользователем из списка
|
||||
let user_id = user.id;
|
||||
// Clone chat_user_ids to avoid borrow conflict
|
||||
let chat_user_ids = client.user_cache().chat_user_ids.clone();
|
||||
client.update_chats(|chats| {
|
||||
chats.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем display name (first_name + last_name)
|
||||
let display_name = if user.last_name.is_empty() {
|
||||
user.first_name.clone()
|
||||
} else {
|
||||
format!("{} {}", user.first_name, user.last_name)
|
||||
};
|
||||
client.update_user_cache(|cache| {
|
||||
cache.user_names.insert(UserId::new(user.id), display_name);
|
||||
});
|
||||
|
||||
// Сохраняем username если есть (с упрощённым извлечением через and_then)
|
||||
if let Some(username) = user
|
||||
.usernames
|
||||
.as_ref()
|
||||
.and_then(|u| u.active_usernames.first())
|
||||
{
|
||||
let affected_chat_ids = client.update_user_cache(|cache| {
|
||||
cache
|
||||
.user_usernames
|
||||
.insert(UserId::new(user.id), username.to_string());
|
||||
cache
|
||||
.chat_user_ids
|
||||
.iter()
|
||||
.filter_map(|(&chat_id, &user_id)| {
|
||||
(user_id == UserId::new(user.id)).then_some(chat_id)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
// Обновляем username в чатах, связанных с этим пользователем
|
||||
for chat_id in affected_chat_ids {
|
||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||
chat.username = Some(format!("@{}", username));
|
||||
});
|
||||
}
|
||||
}
|
||||
// LRU-кэш автоматически удаляет старые записи при вставке
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::MessageInteractionInfo - обновление реакций на сообщение.
|
||||
///
|
||||
/// Обновляет список реакций для сообщения в текущем открытом чате.
|
||||
pub fn handle_message_interaction_info_update(
|
||||
client: &mut TdClient,
|
||||
update: UpdateMessageInteractionInfo,
|
||||
) {
|
||||
// Обновляем реакции в текущем открытом чате
|
||||
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлекаем реакции из interaction_info
|
||||
let reactions = update
|
||||
.interaction_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.reactions.as_ref())
|
||||
.map(|reactions| {
|
||||
reactions
|
||||
.reactions
|
||||
.iter()
|
||||
.filter_map(|reaction| {
|
||||
let emoji = match &reaction.r#type {
|
||||
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
|
||||
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
|
||||
};
|
||||
|
||||
Some(ReactionInfo {
|
||||
emoji,
|
||||
count: reaction.total_count,
|
||||
is_chosen: reaction.is_chosen,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
client.update_current_chat_message(MessageId::new(update.message_id), |msg| {
|
||||
msg.interactions.reactions = reactions;
|
||||
});
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения.
|
||||
///
|
||||
/// Заменяет временный ID сообщения на настоящий ID от сервера,
|
||||
/// сохраняя reply_info из временного сообщения.
|
||||
pub fn handle_message_send_succeeded_update(
|
||||
client: &mut TdClient,
|
||||
update: UpdateMessageSendSucceeded,
|
||||
) {
|
||||
let old_id = MessageId::new(update.old_message_id);
|
||||
let chat_id = ChatId::new(update.message.chat_id);
|
||||
|
||||
// Обрабатываем только если это текущий открытый чат
|
||||
if Some(chat_id) != client.current_chat_id() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Находим сообщение с временным ID
|
||||
let Some(idx) = client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.position(|m| m.id() == old_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Конвертируем новое сообщение
|
||||
let mut new_msg =
|
||||
crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
|
||||
|
||||
// Сохраняем reply_info из старого сообщения (если было)
|
||||
let old_reply = client.current_chat_messages()[idx]
|
||||
.interactions
|
||||
.reply_to
|
||||
.clone();
|
||||
if let Some(reply) = old_reply {
|
||||
new_msg.interactions.reply_to = Some(reply);
|
||||
}
|
||||
|
||||
// Заменяем старое сообщение на новое
|
||||
client.replace_current_chat_message(old_id, new_msg);
|
||||
}
|
||||
|
||||
/// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате.
|
||||
///
|
||||
/// Извлекает текст черновика и сохраняет его в ChatInfo для отображения в списке чатов.
|
||||
pub fn handle_chat_draft_message_update(client: &mut TdClient, update: UpdateChatDraftMessage) {
|
||||
crate::tdlib::chat_helpers::update_chat(client, ChatId::new(update.chat_id), |chat| {
|
||||
chat.draft_text = update.draft_message.as_ref().and_then(|draft| {
|
||||
// Извлекаем текст из InputMessageText с помощью pattern matching
|
||||
match &draft.input_message_text {
|
||||
tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) => {
|
||||
Some(text_msg.text.text.clone())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Обрабатывает изменение состояния авторизации
|
||||
pub fn handle_auth_state(client: &mut TdClient, state: AuthorizationState) {
|
||||
client.auth.state = match state {
|
||||
AuthorizationState::WaitTdlibParameters => AuthState::WaitTdlibParameters,
|
||||
AuthorizationState::WaitPhoneNumber => AuthState::WaitPhoneNumber,
|
||||
AuthorizationState::WaitCode(_) => AuthState::WaitCode,
|
||||
AuthorizationState::WaitPassword(_) => AuthState::WaitPassword,
|
||||
AuthorizationState::Ready => AuthState::Ready,
|
||||
AuthorizationState::Closed => AuthState::Closed,
|
||||
_ => client.auth.state.clone(),
|
||||
};
|
||||
}
|
||||
270
crates/tele-core/src/tdlib/users.rs
Normal file
270
crates/tele-core/src/tdlib/users.rs
Normal file
@@ -0,0 +1,270 @@
|
||||
use crate::constants::{LAZY_LOAD_USERS_PER_TICK, MAX_CHAT_USER_IDS, MAX_USER_CACHE_SIZE};
|
||||
use crate::types::{ChatId, UserId};
|
||||
use std::collections::HashMap;
|
||||
use tdlib_rs::enums::{User, UserStatus};
|
||||
use tdlib_rs::functions;
|
||||
|
||||
use super::types::UserOnlineStatus;
|
||||
|
||||
/// LRU (Least Recently Used) кэш с фиксированной ёмкостью.
|
||||
///
|
||||
/// Автоматически удаляет самые давно использованные элементы при достижении лимита.
|
||||
/// Основан на HashMap для быстрого доступа и Vec для отслеживания порядка использования.
|
||||
///
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `K` - Тип ключа (должен реализовывать `Eq + Hash + Clone + Copy`)
|
||||
/// * `V` - Тип значения (должен реализовывать `Clone`)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut cache = LruCache::<UserId, String>::new(100);
|
||||
/// cache.insert(UserId::new(1), "Alice".to_string());
|
||||
/// assert_eq!(cache.get(&UserId::new(1)), Some(&"Alice".to_string()));
|
||||
/// ```
|
||||
pub struct LruCache<K, V> {
|
||||
/// Хранилище ключ-значение.
|
||||
map: HashMap<K, V>,
|
||||
|
||||
/// Порядок доступа: последний элемент — самый недавно использованный.
|
||||
order: Vec<K>,
|
||||
|
||||
/// Максимальная ёмкость кэша.
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl<K, V> LruCache<K, V>
|
||||
where
|
||||
K: Eq + std::hash::Hash + Clone + Copy,
|
||||
V: Clone,
|
||||
{
|
||||
/// Создает новый LRU кэш с заданной ёмкостью.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
map: HashMap::with_capacity(capacity),
|
||||
order: Vec::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Получает значение и обновляет порядок доступа (помечает как использованное).
|
||||
pub fn get(&mut self, key: &K) -> Option<&V> {
|
||||
if self.map.contains_key(key) {
|
||||
// Перемещаем ключ в конец (самый недавно использованный)
|
||||
self.order.retain(|k| k != key);
|
||||
self.order.push(*key);
|
||||
self.map.get(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить значение без обновления порядка (для read-only доступа)
|
||||
pub fn peek(&self, key: &K) -> Option<&V> {
|
||||
self.map.get(key)
|
||||
}
|
||||
|
||||
/// Вставить значение
|
||||
pub fn insert(&mut self, key: K, value: V) {
|
||||
if self.map.contains_key(&key) {
|
||||
// Обновляем существующее значение
|
||||
self.map.insert(key, value);
|
||||
self.order.retain(|k| *k != key);
|
||||
self.order.push(key);
|
||||
} else {
|
||||
// Если кэш полон, удаляем самый старый элемент
|
||||
if self.map.len() >= self.capacity {
|
||||
if let Some(oldest) = self.order.first().copied() {
|
||||
self.order.remove(0);
|
||||
self.map.remove(&oldest);
|
||||
}
|
||||
}
|
||||
self.map.insert(key, value);
|
||||
self.order.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверить наличие ключа
|
||||
pub fn contains_key(&self, key: &K) -> bool {
|
||||
self.map.contains_key(key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Кэш информации о пользователях Telegram.
|
||||
///
|
||||
/// Хранит данные пользователей (имена, usernames, статусы) в LRU-кэшах
|
||||
/// для быстрого доступа без повторных запросов к TDLib.
|
||||
///
|
||||
/// # Возможности
|
||||
///
|
||||
/// - Кэширование имен пользователей (first_name + last_name)
|
||||
/// - Кэширование usernames (@username)
|
||||
/// - Кэширование онлайн-статусов
|
||||
/// - Связь chat_id → user_id для приватных чатов
|
||||
/// - Ленивая загрузка данных пользователей порциями
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let mut cache = UserCache::new(client_id);
|
||||
///
|
||||
/// // Обработать обновление пользователя
|
||||
/// cache.handle_user_update(&user_enum);
|
||||
///
|
||||
/// // Получить имя
|
||||
/// let name = cache.get_user_name(user_id).await;
|
||||
/// ```
|
||||
pub struct UserCache {
|
||||
/// LRU-кэш usernames: user_id → username.
|
||||
pub user_usernames: LruCache<UserId, String>,
|
||||
|
||||
/// LRU-кэш имён: user_id → display_name (first_name + last_name).
|
||||
pub user_names: LruCache<UserId, String>,
|
||||
|
||||
/// Связь chat_id → user_id для приватных чатов.
|
||||
pub chat_user_ids: HashMap<ChatId, UserId>,
|
||||
|
||||
/// Очередь user_id для ленивой загрузки имён.
|
||||
pub pending_user_ids: Vec<UserId>,
|
||||
|
||||
/// LRU-кэш онлайн-статусов: user_id → status.
|
||||
pub user_statuses: LruCache<UserId, UserOnlineStatus>,
|
||||
|
||||
/// ID клиента TDLib для API вызовов.
|
||||
client_id: i32,
|
||||
}
|
||||
|
||||
impl UserCache {
|
||||
/// Создает новый кэш пользователей.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||
pub fn new(client_id: i32) -> Self {
|
||||
Self {
|
||||
user_usernames: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||
user_names: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||
chat_user_ids: HashMap::with_capacity(MAX_CHAT_USER_IDS),
|
||||
pending_user_ids: Vec::new(),
|
||||
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить статус пользователя по chat_id
|
||||
pub fn get_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
let user_id = self.chat_user_ids.get(&chat_id)?;
|
||||
self.user_statuses.peek(user_id)
|
||||
}
|
||||
|
||||
/// Обрабатывает обновление пользователя от TDLib.
|
||||
///
|
||||
/// Сохраняет username, имя и статус пользователя в соответствующие кэши.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `user_enum` - Обновление пользователя от TDLib
|
||||
pub fn handle_user_update(&mut self, user_enum: &User) {
|
||||
let User::User(user) = user_enum;
|
||||
let user_id = user.id;
|
||||
|
||||
// Сохраняем username
|
||||
if let Some(username) = user.usernames.as_ref().map(|u| u.editable_username.clone()) {
|
||||
self.user_usernames.insert(UserId::new(user_id), username);
|
||||
}
|
||||
|
||||
// Сохраняем имя
|
||||
let display_name = format!("{} {}", user.first_name, user.last_name)
|
||||
.trim()
|
||||
.to_string();
|
||||
self.user_names.insert(UserId::new(user_id), display_name);
|
||||
|
||||
// Обновляем статус
|
||||
self.update_status(UserId::new(user_id), &user.status);
|
||||
}
|
||||
|
||||
/// Обновляет онлайн-статус пользователя.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `user_id` - ID пользователя
|
||||
/// * `status` - Новый статус от TDLib
|
||||
pub fn update_status(&mut self, user_id: UserId, status: &UserStatus) {
|
||||
let online_status = match status {
|
||||
UserStatus::Online(_) => UserOnlineStatus::Online,
|
||||
UserStatus::Recently(_) => UserOnlineStatus::Recently,
|
||||
UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek,
|
||||
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
|
||||
UserStatus::Offline(s) => UserOnlineStatus::Offline(s.was_online),
|
||||
_ => return,
|
||||
};
|
||||
self.user_statuses.insert(user_id, online_status);
|
||||
}
|
||||
|
||||
/// Получает имя пользователя из кэша или загружает из TDLib.
|
||||
///
|
||||
/// Сначала проверяет кэш, затем при необходимости загружает из API.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `user_id` - ID пользователя
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_user_name(&self, user_id: UserId) -> String {
|
||||
// Сначала пытаемся получить из кэша
|
||||
if let Some(name) = self.user_names.peek(&user_id) {
|
||||
return name.clone();
|
||||
}
|
||||
|
||||
// Загружаем пользователя
|
||||
match functions::get_user(user_id.as_i64(), self.client_id).await {
|
||||
Ok(User::User(user)) => {
|
||||
let name = format!("{} {}", user.first_name, user.last_name)
|
||||
.trim()
|
||||
.to_string();
|
||||
name
|
||||
}
|
||||
_ => format!("User {}", user_id.as_i64()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает очередь отложенных user_ids для ленивой загрузки.
|
||||
///
|
||||
/// Загружает данные пользователей небольшими порциями (по [`LAZY_LOAD_USERS_PER_TICK`])
|
||||
/// для избежания блокировки UI.
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Вызывайте периодически в основном цикле приложения.
|
||||
pub async fn process_pending_user_ids(&mut self) {
|
||||
if self.pending_user_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Берём первые N user_ids для загрузки
|
||||
let batch: Vec<UserId> = self
|
||||
.pending_user_ids
|
||||
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
|
||||
.collect();
|
||||
|
||||
for user_id in batch {
|
||||
if self.user_names.contains_key(&user_id) {
|
||||
continue; // Уже в кэше
|
||||
}
|
||||
|
||||
match functions::get_user(user_id.as_i64(), self.client_id).await {
|
||||
Ok(user_enum) => {
|
||||
self.handle_user_update(&user_enum);
|
||||
}
|
||||
Err(_) => {
|
||||
// Если не удалось загрузить, сохраняем placeholder
|
||||
self.user_names.insert(user_id, format!("User {}", user_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/tele-core/src/test_support/fake_tdclient.rs
Normal file
12
crates/tele-core/src/test_support/fake_tdclient.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Fake TDLib client for testing.
|
||||
|
||||
mod builders;
|
||||
mod inspect;
|
||||
mod operations;
|
||||
mod state;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use state::{
|
||||
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages,
|
||||
SearchQuery, SentMessage, TdUpdate, ViewedMessages,
|
||||
};
|
||||
86
crates/tele-core/src/test_support/fake_tdclient/builders.rs
Normal file
86
crates/tele-core/src/test_support/fake_tdclient/builders.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use super::{FakeTdClient, TdUpdate};
|
||||
use crate::tdlib::types::FolderInfo;
|
||||
use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl FakeTdClient {
|
||||
/// Create an update channel for receiving simulated TDLib events.
|
||||
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
*self.update_tx.lock().unwrap() = Some(tx);
|
||||
(self, rx)
|
||||
}
|
||||
|
||||
/// Enable simulated delays, closer to real TDLib behavior.
|
||||
pub fn with_delays(mut self) -> Self {
|
||||
self.simulate_delays = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_chat(self, chat: ChatInfo) -> Self {
|
||||
self.chats.lock().unwrap().push(chat);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_chats(self, chats: Vec<ChatInfo>) -> Self {
|
||||
self.chats.lock().unwrap().extend(chats);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self {
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(chat_id)
|
||||
.or_default()
|
||||
.push(message);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
|
||||
self.messages.lock().unwrap().insert(chat_id, messages);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_folder(self, id: i32, name: &str) -> Self {
|
||||
self.folders
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(FolderInfo { id, name: name.to_string() });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_user(self, id: i64, name: &str) -> Self {
|
||||
self.user_names.lock().unwrap().insert(id, name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self {
|
||||
self.profiles.lock().unwrap().insert(chat_id, profile);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_network_state(self, state: NetworkState) -> Self {
|
||||
*self.network_state.lock().unwrap() = state;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_auth_state(self, state: AuthState) -> Self {
|
||||
*self.auth_state.lock().unwrap() = state;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
|
||||
self.downloaded_files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(file_id, path.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
|
||||
*self.available_reactions.lock().unwrap() = reactions;
|
||||
self
|
||||
}
|
||||
}
|
||||
92
crates/tele-core/src/test_support/fake_tdclient/inspect.rs
Normal file
92
crates/tele-core/src/test_support/fake_tdclient/inspect.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use super::{
|
||||
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
|
||||
TdUpdate,
|
||||
};
|
||||
use crate::tdlib::types::FolderInfo;
|
||||
use crate::tdlib::{ChatInfo, MessageInfo, NetworkState};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl FakeTdClient {
|
||||
pub fn get_chats(&self) -> Vec<ChatInfo> {
|
||||
self.chats.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_folders(&self) -> Vec<FolderInfo> {
|
||||
self.folders.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&chat_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
|
||||
self.sent_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
|
||||
self.edited_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
|
||||
self.deleted_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
|
||||
self.forwarded_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
|
||||
self.searched_queries.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
|
||||
self.viewed_messages.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
|
||||
self.chat_actions.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_network_state(&self) -> NetworkState {
|
||||
self.network_state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn get_current_chat_id(&self) -> Option<i64> {
|
||||
*self.current_chat_id.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
*self.current_pinned_message.lock().unwrap() = msg;
|
||||
}
|
||||
|
||||
pub async fn process_pending_view_messages(&mut self) {
|
||||
let mut pending = self.pending_view_messages.lock().unwrap();
|
||||
for (chat_id, message_ids) in pending.drain(..) {
|
||||
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||
self.viewed_messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((chat_id.as_i64(), ids));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
|
||||
*self.update_tx.lock().unwrap() = Some(tx);
|
||||
}
|
||||
|
||||
pub fn clear_all_history(&self) {
|
||||
self.sent_messages.lock().unwrap().clear();
|
||||
self.edited_messages.lock().unwrap().clear();
|
||||
self.deleted_messages.lock().unwrap().clear();
|
||||
self.forwarded_messages.lock().unwrap().clear();
|
||||
self.searched_queries.lock().unwrap().clear();
|
||||
self.viewed_messages.lock().unwrap().clear();
|
||||
self.chat_actions.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
458
crates/tele-core/src/test_support/fake_tdclient/operations.rs
Normal file
458
crates/tele-core/src/test_support/fake_tdclient/operations.rs
Normal file
@@ -0,0 +1,458 @@
|
||||
use super::{
|
||||
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
|
||||
TdUpdate,
|
||||
};
|
||||
use crate::tdlib::types::ReactionInfo;
|
||||
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl FakeTdClient {
|
||||
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to load chats".to_string());
|
||||
}
|
||||
|
||||
if self.simulate_delays {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
let chats = self
|
||||
.chats
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(chats)
|
||||
}
|
||||
|
||||
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to open chat".to_string());
|
||||
}
|
||||
|
||||
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_chat_history(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to load history".to_string());
|
||||
}
|
||||
|
||||
if self.simulate_delays {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let messages = self
|
||||
.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&chat_id.as_i64())
|
||||
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub async fn load_older_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to load older messages".to_string());
|
||||
}
|
||||
|
||||
let messages = self.messages.lock().unwrap();
|
||||
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
|
||||
|
||||
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
|
||||
let older = chat_messages.iter().take(idx).cloned().collect();
|
||||
Ok(older)
|
||||
} else {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to send message".to_string());
|
||||
}
|
||||
|
||||
if self.simulate_delays {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
|
||||
|
||||
self.sent_messages.lock().unwrap().push(SentMessage {
|
||||
chat_id: chat_id.as_i64(),
|
||||
text: text.clone(),
|
||||
reply_to,
|
||||
reply_info: reply_info.clone(),
|
||||
});
|
||||
|
||||
let message = MessageInfo::new(
|
||||
message_id,
|
||||
"You".to_string(),
|
||||
true,
|
||||
text,
|
||||
vec![],
|
||||
chrono::Utc::now().timestamp() as i32,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
reply_info,
|
||||
None,
|
||||
vec![],
|
||||
);
|
||||
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(chat_id.as_i64())
|
||||
.or_default()
|
||||
.push(message.clone());
|
||||
|
||||
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) });
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to edit message".to_string());
|
||||
}
|
||||
|
||||
if self.simulate_delays {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||
}
|
||||
|
||||
self.edited_messages.lock().unwrap().push(EditedMessage {
|
||||
chat_id: chat_id.as_i64(),
|
||||
message_id,
|
||||
new_text: new_text.clone(),
|
||||
});
|
||||
|
||||
let mut messages = self.messages.lock().unwrap();
|
||||
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
||||
msg.content.text = new_text.clone();
|
||||
msg.metadata.edit_date = msg.metadata.date + 60;
|
||||
|
||||
let updated = msg.clone();
|
||||
drop(messages);
|
||||
|
||||
self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text });
|
||||
|
||||
return Ok(updated);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Message not found".to_string())
|
||||
}
|
||||
|
||||
pub async fn delete_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to delete messages".to_string());
|
||||
}
|
||||
|
||||
if self.simulate_delays {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
self.deleted_messages.lock().unwrap().push(DeletedMessages {
|
||||
chat_id: chat_id.as_i64(),
|
||||
message_ids: message_ids.clone(),
|
||||
revoke,
|
||||
});
|
||||
|
||||
let mut messages = self.messages.lock().unwrap();
|
||||
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||
chat_msgs.retain(|m| !message_ids.contains(&m.id()));
|
||||
}
|
||||
drop(messages);
|
||||
|
||||
self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn forward_messages(
|
||||
&self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to forward messages".to_string());
|
||||
}
|
||||
|
||||
if self.simulate_delays {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||
}
|
||||
|
||||
self.forwarded_messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(ForwardedMessages {
|
||||
from_chat_id: from_chat_id.as_i64(),
|
||||
to_chat_id: to_chat_id.as_i64(),
|
||||
message_ids,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to search messages".to_string());
|
||||
}
|
||||
|
||||
let messages = self.messages.lock().unwrap();
|
||||
let results: Vec<_> = messages
|
||||
.get(&chat_id.as_i64())
|
||||
.map(|msgs| {
|
||||
msgs.iter()
|
||||
.filter(|m| m.text().to_lowercase().contains(&query.to_lowercase()))
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
self.searched_queries.lock().unwrap().push(SearchQuery {
|
||||
chat_id: chat_id.as_i64(),
|
||||
query: query.to_string(),
|
||||
results_count: results.len(),
|
||||
});
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
if text.is_empty() {
|
||||
self.drafts.lock().unwrap().remove(&chat_id.as_i64());
|
||||
} else {
|
||||
self.drafts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(chat_id.as_i64(), text.clone());
|
||||
}
|
||||
|
||||
self.send_update(TdUpdate::ChatDraftMessage {
|
||||
chat_id,
|
||||
draft_text: if text.is_empty() { None } else { Some(text) },
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
|
||||
self.chat_actions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((chat_id.as_i64(), action.clone()));
|
||||
|
||||
if action == "Typing" {
|
||||
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
|
||||
} else if action == "Cancel" {
|
||||
*self.typing_chat_id.lock().unwrap() = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_message_available_reactions(
|
||||
&self,
|
||||
_chat_id: ChatId,
|
||||
_message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to get available reactions".to_string());
|
||||
}
|
||||
|
||||
Ok(self.available_reactions.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
pub async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
emoji: String,
|
||||
) -> Result<(), String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to toggle reaction".to_string());
|
||||
}
|
||||
|
||||
let mut messages = self.messages.lock().unwrap();
|
||||
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
||||
let reactions = &mut msg.interactions.reactions;
|
||||
|
||||
if let Some(pos) = reactions
|
||||
.iter()
|
||||
.position(|reaction| reaction.emoji == emoji && reaction.is_chosen)
|
||||
{
|
||||
reactions.remove(pos);
|
||||
} else if let Some(reaction) = reactions
|
||||
.iter_mut()
|
||||
.find(|reaction| reaction.emoji == emoji)
|
||||
{
|
||||
reaction.is_chosen = true;
|
||||
reaction.count += 1;
|
||||
} else {
|
||||
reactions.push(ReactionInfo {
|
||||
emoji: emoji.clone(),
|
||||
count: 1,
|
||||
is_chosen: true,
|
||||
});
|
||||
}
|
||||
|
||||
let updated_reactions = reactions.clone();
|
||||
drop(messages);
|
||||
|
||||
self.send_update(TdUpdate::MessageInteractionInfo {
|
||||
chat_id,
|
||||
message_id,
|
||||
reactions: updated_reactions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to download file".to_string());
|
||||
}
|
||||
|
||||
self.downloaded_files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&file_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("File {} not found", file_id))
|
||||
}
|
||||
|
||||
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to get profile info".to_string());
|
||||
}
|
||||
|
||||
self.profiles
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&chat_id.as_i64())
|
||||
.cloned()
|
||||
.ok_or_else(|| "Profile not found".to_string())
|
||||
}
|
||||
|
||||
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
self.viewed_messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
|
||||
}
|
||||
|
||||
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
|
||||
if self.should_fail() {
|
||||
return Err("Failed to load folder chats".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_update(&self, update: TdUpdate) {
|
||||
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
|
||||
let _ = tx.send(update);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_fail(&self) -> bool {
|
||||
let mut fail = self.fail_next_operation.lock().unwrap();
|
||||
if *fail {
|
||||
*fail = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail_next(&self) {
|
||||
*self.fail_next_operation.lock().unwrap() = true;
|
||||
}
|
||||
|
||||
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
|
||||
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
|
||||
|
||||
let message = MessageInfo::new(
|
||||
message_id,
|
||||
sender_name.to_string(),
|
||||
false,
|
||||
text,
|
||||
vec![],
|
||||
chrono::Utc::now().timestamp() as i32,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
);
|
||||
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(chat_id.as_i64())
|
||||
.or_default()
|
||||
.push(message.clone());
|
||||
|
||||
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) });
|
||||
}
|
||||
|
||||
pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) {
|
||||
self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() });
|
||||
}
|
||||
|
||||
pub fn simulate_network_change(&self, state: crate::tdlib::NetworkState) {
|
||||
*self.network_state.lock().unwrap() = state.clone();
|
||||
self.send_update(TdUpdate::ConnectionState { state });
|
||||
}
|
||||
|
||||
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
|
||||
self.send_update(TdUpdate::ChatReadOutbox {
|
||||
chat_id,
|
||||
last_read_outbox_message_id: last_read_message_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
201
crates/tele-core/src/test_support/fake_tdclient/state.rs
Normal file
201
crates/tele-core/src/test_support/fake_tdclient/state.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use crate::tdlib::types::{FolderInfo, ReactionInfo};
|
||||
use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub type ViewedMessages = Vec<(i64, Vec<i64>)>;
|
||||
pub type PendingViewMessages = Vec<(ChatId, Vec<MessageId>)>;
|
||||
|
||||
/// Update events from TDLib, simplified for tests.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum TdUpdate {
|
||||
NewMessage {
|
||||
chat_id: ChatId,
|
||||
message: Box<MessageInfo>,
|
||||
},
|
||||
MessageContent {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
},
|
||||
DeleteMessages {
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
},
|
||||
ChatAction {
|
||||
chat_id: ChatId,
|
||||
user_id: UserId,
|
||||
action: String,
|
||||
},
|
||||
MessageInteractionInfo {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
},
|
||||
ConnectionState {
|
||||
state: NetworkState,
|
||||
},
|
||||
ChatReadOutbox {
|
||||
chat_id: ChatId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
},
|
||||
ChatDraftMessage {
|
||||
chat_id: ChatId,
|
||||
draft_text: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Simplified mock TDLib client for tests.
|
||||
#[allow(dead_code)]
|
||||
pub struct FakeTdClient {
|
||||
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
|
||||
pub messages: Arc<Mutex<HashMap<i64, Vec<MessageInfo>>>>,
|
||||
pub folders: Arc<Mutex<Vec<FolderInfo>>>,
|
||||
pub user_names: Arc<Mutex<HashMap<i64, String>>>,
|
||||
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
|
||||
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
|
||||
pub available_reactions: Arc<Mutex<Vec<String>>>,
|
||||
|
||||
pub network_state: Arc<Mutex<NetworkState>>,
|
||||
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
|
||||
pub current_chat_id: Arc<Mutex<Option<i64>>>,
|
||||
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
|
||||
pub auth_state: Arc<Mutex<AuthState>>,
|
||||
|
||||
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
|
||||
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
|
||||
pub deleted_messages: Arc<Mutex<Vec<DeletedMessages>>>,
|
||||
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
|
||||
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
|
||||
pub viewed_messages: Arc<Mutex<ViewedMessages>>,
|
||||
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>,
|
||||
pub pending_view_messages: Arc<Mutex<PendingViewMessages>>,
|
||||
|
||||
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
||||
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
||||
|
||||
pub simulate_delays: bool,
|
||||
pub fail_next_operation: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SentMessage {
|
||||
pub chat_id: i64,
|
||||
pub text: String,
|
||||
pub reply_to: Option<MessageId>,
|
||||
pub reply_info: Option<ReplyInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct EditedMessage {
|
||||
pub chat_id: i64,
|
||||
pub message_id: MessageId,
|
||||
pub new_text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct DeletedMessages {
|
||||
pub chat_id: i64,
|
||||
pub message_ids: Vec<MessageId>,
|
||||
pub revoke: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ForwardedMessages {
|
||||
pub from_chat_id: i64,
|
||||
pub to_chat_id: i64,
|
||||
pub message_ids: Vec<MessageId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SearchQuery {
|
||||
pub chat_id: i64,
|
||||
pub query: String,
|
||||
pub results_count: usize,
|
||||
}
|
||||
|
||||
impl Default for FakeTdClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for FakeTdClient {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
chats: Arc::clone(&self.chats),
|
||||
messages: Arc::clone(&self.messages),
|
||||
folders: Arc::clone(&self.folders),
|
||||
user_names: Arc::clone(&self.user_names),
|
||||
profiles: Arc::clone(&self.profiles),
|
||||
drafts: Arc::clone(&self.drafts),
|
||||
available_reactions: Arc::clone(&self.available_reactions),
|
||||
network_state: Arc::clone(&self.network_state),
|
||||
typing_chat_id: Arc::clone(&self.typing_chat_id),
|
||||
current_chat_id: Arc::clone(&self.current_chat_id),
|
||||
current_pinned_message: Arc::clone(&self.current_pinned_message),
|
||||
auth_state: Arc::clone(&self.auth_state),
|
||||
sent_messages: Arc::clone(&self.sent_messages),
|
||||
edited_messages: Arc::clone(&self.edited_messages),
|
||||
deleted_messages: Arc::clone(&self.deleted_messages),
|
||||
forwarded_messages: Arc::clone(&self.forwarded_messages),
|
||||
searched_queries: Arc::clone(&self.searched_queries),
|
||||
viewed_messages: Arc::clone(&self.viewed_messages),
|
||||
chat_actions: Arc::clone(&self.chat_actions),
|
||||
pending_view_messages: Arc::clone(&self.pending_view_messages),
|
||||
downloaded_files: Arc::clone(&self.downloaded_files),
|
||||
update_tx: Arc::clone(&self.update_tx),
|
||||
simulate_delays: self.simulate_delays,
|
||||
fail_next_operation: Arc::clone(&self.fail_next_operation),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl FakeTdClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
chats: Arc::new(Mutex::new(vec![])),
|
||||
messages: Arc::new(Mutex::new(HashMap::new())),
|
||||
folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])),
|
||||
user_names: Arc::new(Mutex::new(HashMap::new())),
|
||||
profiles: Arc::new(Mutex::new(HashMap::new())),
|
||||
drafts: Arc::new(Mutex::new(HashMap::new())),
|
||||
available_reactions: Arc::new(Mutex::new(vec![
|
||||
"👍".to_string(),
|
||||
"❤️".to_string(),
|
||||
"😂".to_string(),
|
||||
"😮".to_string(),
|
||||
"😢".to_string(),
|
||||
"🙏".to_string(),
|
||||
"👏".to_string(),
|
||||
"🔥".to_string(),
|
||||
])),
|
||||
network_state: Arc::new(Mutex::new(NetworkState::Ready)),
|
||||
typing_chat_id: Arc::new(Mutex::new(None)),
|
||||
current_chat_id: Arc::new(Mutex::new(None)),
|
||||
current_pinned_message: Arc::new(Mutex::new(None)),
|
||||
auth_state: Arc::new(Mutex::new(AuthState::Ready)),
|
||||
sent_messages: Arc::new(Mutex::new(vec![])),
|
||||
edited_messages: Arc::new(Mutex::new(vec![])),
|
||||
deleted_messages: Arc::new(Mutex::new(vec![])),
|
||||
forwarded_messages: Arc::new(Mutex::new(vec![])),
|
||||
searched_queries: Arc::new(Mutex::new(vec![])),
|
||||
viewed_messages: Arc::new(Mutex::new(vec![])),
|
||||
chat_actions: Arc::new(Mutex::new(vec![])),
|
||||
pending_view_messages: Arc::new(Mutex::new(vec![])),
|
||||
downloaded_files: Arc::new(Mutex::new(HashMap::new())),
|
||||
update_tx: Arc::new(Mutex::new(None)),
|
||||
simulate_delays: false,
|
||||
fail_next_operation: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
358
crates/tele-core/src/test_support/fake_tdclient_impl.rs
Normal file
358
crates/tele-core/src/test_support/fake_tdclient_impl.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
//! Test implementation of the TDLib client traits for FakeTdClient.
|
||||
|
||||
use super::fake_tdclient::FakeTdClient;
|
||||
use crate::tdlib::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, ReactionClient, UpdateClient, UserClient,
|
||||
};
|
||||
use crate::tdlib::{
|
||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||
UserOnlineStatus,
|
||||
};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::borrow::Cow;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
#[async_trait]
|
||||
impl AuthClient for FakeTdClient {
|
||||
async fn send_phone_number(&self, _phone: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_code(&self, _code: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_password(&self, _password: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatClient for FakeTdClient {
|
||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||
let _ = FakeTdClient::load_chats(self, limit as usize).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
|
||||
FakeTdClient::load_folder_chats(self, folder_id, limit as usize).await
|
||||
}
|
||||
|
||||
async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||
FakeTdClient::get_profile_info(self, chat_id).await
|
||||
}
|
||||
|
||||
fn chats(&self) -> &[ChatInfo] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn folders(&self) -> &[FolderInfo] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn main_chat_list_position(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
fn set_main_chat_list_position(&mut self, _position: i32) {}
|
||||
|
||||
fn update_chats<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<ChatInfo>),
|
||||
{
|
||||
updater(&mut self.chats.lock().unwrap());
|
||||
}
|
||||
|
||||
fn update_folders<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<FolderInfo>),
|
||||
{
|
||||
updater(&mut self.folders.lock().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ChatActionClient for FakeTdClient {
|
||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||
FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await;
|
||||
}
|
||||
|
||||
fn clear_stale_typing_status(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) {}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MessageClient for FakeTdClient {
|
||||
async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
FakeTdClient::get_chat_history(self, chat_id, limit).await
|
||||
}
|
||||
|
||||
async fn load_older_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
FakeTdClient::load_older_messages(self, chat_id, from_message_id).await
|
||||
}
|
||||
|
||||
async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {}
|
||||
|
||||
async fn search_messages(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
FakeTdClient::search_messages(self, chat_id, query).await
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
FakeTdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
FakeTdClient::edit_message(self, chat_id, message_id, new_text).await
|
||||
}
|
||||
|
||||
async fn delete_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
revoke: bool,
|
||||
) -> Result<(), String> {
|
||||
FakeTdClient::delete_messages(self, chat_id, message_ids, revoke).await
|
||||
}
|
||||
|
||||
async fn forward_messages(
|
||||
&mut self,
|
||||
to_chat_id: ChatId,
|
||||
from_chat_id: ChatId,
|
||||
message_ids: Vec<MessageId>,
|
||||
) -> Result<(), String> {
|
||||
FakeTdClient::forward_messages(self, from_chat_id, to_chat_id, message_ids).await
|
||||
}
|
||||
|
||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||
FakeTdClient::set_draft_message(self, chat_id, text).await
|
||||
}
|
||||
|
||||
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> {
|
||||
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||
Cow::Owned(self.get_messages(chat_id))
|
||||
} else {
|
||||
Cow::Owned(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn current_chat_id(&self) -> Option<ChatId> {
|
||||
self.get_current_chat_id().map(ChatId::new)
|
||||
}
|
||||
|
||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||
self.current_pinned_message.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn push_message(&mut self, msg: MessageInfo) {
|
||||
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||
self.messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(chat_id)
|
||||
.or_default()
|
||||
.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_current_chat_messages(&mut self) {
|
||||
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||
self.messages.lock().unwrap().remove(&chat_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||
self.messages.lock().unwrap().insert(chat_id, messages);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||
where
|
||||
F: FnOnce(&mut Vec<MessageInfo>),
|
||||
{
|
||||
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||
let mut all_messages = self.messages.lock().unwrap();
|
||||
updater(all_messages.entry(chat_id).or_default());
|
||||
}
|
||||
}
|
||||
|
||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||
*self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64());
|
||||
}
|
||||
|
||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||
*self.current_pinned_message.lock().unwrap() = msg;
|
||||
}
|
||||
|
||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||
self.pending_view_messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((chat_id, message_ids));
|
||||
}
|
||||
|
||||
async fn fetch_missing_reply_info(&mut self) {}
|
||||
|
||||
async fn process_pending_view_messages(&mut self) {
|
||||
let mut pending = self.pending_view_messages.lock().unwrap();
|
||||
for (chat_id, message_ids) in pending.drain(..) {
|
||||
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||
self.viewed_messages
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((chat_id.as_i64(), ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserClient for FakeTdClient {
|
||||
fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||
None
|
||||
}
|
||||
|
||||
fn pending_user_ids(&self) -> &[UserId] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn user_cache(&self) -> &UserCache {
|
||||
use std::sync::OnceLock;
|
||||
static EMPTY_CACHE: OnceLock<UserCache> = OnceLock::new();
|
||||
EMPTY_CACHE.get_or_init(|| UserCache::new(0))
|
||||
}
|
||||
|
||||
fn update_user_cache<F>(&mut self, _updater: F)
|
||||
where
|
||||
F: FnOnce(&mut UserCache),
|
||||
{
|
||||
}
|
||||
|
||||
async fn process_pending_user_ids(&mut self) {}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReactionClient for FakeTdClient {
|
||||
async fn get_message_available_reactions(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
) -> Result<Vec<String>, String> {
|
||||
FakeTdClient::get_message_available_reactions(self, chat_id, message_id).await
|
||||
}
|
||||
|
||||
async fn toggle_reaction(
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
reaction: String,
|
||||
) -> Result<(), String> {
|
||||
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileClient for FakeTdClient {
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
FakeTdClient::download_file(self, file_id).await
|
||||
}
|
||||
|
||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||
Ok(format!("/tmp/fake_voice_{}.ogg", file_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientState for FakeTdClient {
|
||||
fn client_id(&self) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
async fn get_me(&self) -> Result<i64, String> {
|
||||
Ok(12345)
|
||||
}
|
||||
|
||||
fn auth_state(&self) -> &AuthState {
|
||||
use std::sync::OnceLock;
|
||||
static AUTH_STATE_READY: AuthState = AuthState::Ready;
|
||||
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
|
||||
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
|
||||
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
|
||||
|
||||
let current = self.auth_state.lock().unwrap();
|
||||
match *current {
|
||||
AuthState::Ready => &AUTH_STATE_READY,
|
||||
AuthState::WaitPhoneNumber => {
|
||||
AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber)
|
||||
}
|
||||
AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode),
|
||||
AuthState::WaitPassword => {
|
||||
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
|
||||
}
|
||||
_ => &AUTH_STATE_READY,
|
||||
}
|
||||
}
|
||||
|
||||
fn network_state(&self) -> crate::tdlib::types::NetworkState {
|
||||
FakeTdClient::get_network_state(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountClient for FakeTdClient {
|
||||
async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateClient for FakeTdClient {
|
||||
fn handle_update(&mut self, _update: Update) {}
|
||||
|
||||
fn drain_incoming_message_events(&mut self) -> Vec<crate::tdlib::IncomingMessageEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
7
crates/tele-core/src/test_support/mod.rs
Normal file
7
crates/tele-core/src/test_support/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Core test support for deterministic TDLib fixtures.
|
||||
|
||||
pub mod fake_tdclient;
|
||||
mod fake_tdclient_impl;
|
||||
pub mod test_data;
|
||||
|
||||
pub use fake_tdclient::FakeTdClient;
|
||||
252
crates/tele-core/src/test_support/test_data.rs
Normal file
252
crates/tele-core/src/test_support/test_data.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
// Test data builders and fixtures
|
||||
|
||||
use crate::tdlib::types::{ForwardInfo, ReactionInfo};
|
||||
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
|
||||
/// Builder для создания тестового чата
|
||||
#[allow(dead_code)]
|
||||
pub struct TestChatBuilder {
|
||||
id: i64,
|
||||
title: String,
|
||||
username: Option<String>,
|
||||
last_message: String,
|
||||
last_message_date: i32,
|
||||
unread_count: i32,
|
||||
unread_mention_count: i32,
|
||||
is_pinned: bool,
|
||||
order: i64,
|
||||
last_read_outbox_message_id: i64,
|
||||
folder_ids: Vec<i32>,
|
||||
is_muted: bool,
|
||||
draft_text: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TestChatBuilder {
|
||||
pub fn new(title: &str, id: i64) -> Self {
|
||||
Self {
|
||||
id,
|
||||
title: title.to_string(),
|
||||
username: None,
|
||||
last_message: "".to_string(),
|
||||
last_message_date: 1640000000,
|
||||
unread_count: 0,
|
||||
unread_mention_count: 0,
|
||||
is_pinned: false,
|
||||
order: id,
|
||||
last_read_outbox_message_id: 0,
|
||||
folder_ids: vec![0],
|
||||
is_muted: false,
|
||||
draft_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn username(mut self, username: &str) -> Self {
|
||||
self.username = Some(username.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn last_message(mut self, text: &str) -> Self {
|
||||
self.last_message = text.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unread_count(mut self, count: i32) -> Self {
|
||||
self.unread_count = count;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unread_mentions(mut self, count: i32) -> Self {
|
||||
self.unread_mention_count = count;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn pinned(mut self) -> Self {
|
||||
self.is_pinned = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn muted(mut self) -> Self {
|
||||
self.is_muted = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn draft(mut self, text: &str) -> Self {
|
||||
self.draft_text = Some(text.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn folder(mut self, folder_id: i32) -> Self {
|
||||
self.folder_ids = vec![folder_id];
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> ChatInfo {
|
||||
ChatInfo {
|
||||
id: ChatId::new(self.id),
|
||||
title: self.title,
|
||||
username: self.username,
|
||||
last_message: self.last_message,
|
||||
last_message_date: self.last_message_date,
|
||||
unread_count: self.unread_count,
|
||||
unread_mention_count: self.unread_mention_count,
|
||||
is_pinned: self.is_pinned,
|
||||
order: self.order,
|
||||
last_read_outbox_message_id: MessageId::new(self.last_read_outbox_message_id),
|
||||
folder_ids: self.folder_ids,
|
||||
is_muted: self.is_muted,
|
||||
draft_text: self.draft_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder для создания тестового сообщения
|
||||
#[allow(dead_code)]
|
||||
pub struct TestMessageBuilder {
|
||||
id: i64,
|
||||
sender_name: String,
|
||||
is_outgoing: bool,
|
||||
content: String,
|
||||
entities: Vec<tdlib_rs::types::TextEntity>,
|
||||
date: i32,
|
||||
edit_date: i32,
|
||||
is_read: bool,
|
||||
can_be_edited: bool,
|
||||
can_be_deleted_only_for_self: bool,
|
||||
can_be_deleted_for_all_users: bool,
|
||||
reply_to: Option<ReplyInfo>,
|
||||
forward_from: Option<ForwardInfo>,
|
||||
reactions: Vec<ReactionInfo>,
|
||||
media_album_id: i64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TestMessageBuilder {
|
||||
pub fn new(content: &str, id: i64) -> Self {
|
||||
Self {
|
||||
id,
|
||||
sender_name: "User".to_string(),
|
||||
is_outgoing: false,
|
||||
content: content.to_string(),
|
||||
entities: vec![],
|
||||
date: 1640000000,
|
||||
edit_date: 0,
|
||||
is_read: true,
|
||||
can_be_edited: false,
|
||||
can_be_deleted_only_for_self: true,
|
||||
can_be_deleted_for_all_users: false,
|
||||
reply_to: None,
|
||||
forward_from: None,
|
||||
reactions: vec![],
|
||||
media_album_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn outgoing(mut self) -> Self {
|
||||
self.is_outgoing = true;
|
||||
self.sender_name = "You".to_string();
|
||||
self.can_be_edited = true;
|
||||
self.can_be_deleted_for_all_users = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn sender(mut self, name: &str) -> Self {
|
||||
self.sender_name = name.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn date(mut self, timestamp: i32) -> Self {
|
||||
self.date = timestamp;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn edited(mut self) -> Self {
|
||||
self.edit_date = self.date + 60;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn unread(mut self) -> Self {
|
||||
self.is_read = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reply_to(mut self, message_id: i64, sender: &str, text: &str) -> Self {
|
||||
self.reply_to = Some(ReplyInfo {
|
||||
message_id: MessageId::new(message_id),
|
||||
sender_name: sender.to_string(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn forwarded_from(mut self, sender: &str) -> Self {
|
||||
self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reaction(mut self, emoji: &str, count: i32, chosen: bool) -> Self {
|
||||
self.reactions
|
||||
.push(ReactionInfo { emoji: emoji.to_string(), count, is_chosen: chosen });
|
||||
self
|
||||
}
|
||||
|
||||
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||
self.media_album_id = id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> MessageInfo {
|
||||
let mut msg = MessageInfo::new(
|
||||
MessageId::new(self.id),
|
||||
self.sender_name,
|
||||
self.is_outgoing,
|
||||
self.content,
|
||||
self.entities,
|
||||
self.date,
|
||||
self.edit_date,
|
||||
self.is_read,
|
||||
self.can_be_edited,
|
||||
self.can_be_deleted_only_for_self,
|
||||
self.can_be_deleted_for_all_users,
|
||||
self.reply_to,
|
||||
self.forward_from,
|
||||
self.reactions,
|
||||
);
|
||||
msg.metadata.media_album_id = self.media_album_id;
|
||||
msg
|
||||
}
|
||||
}
|
||||
|
||||
/// Хелперы для быстрого создания тестовых данных
|
||||
pub fn create_test_chat(title: &str, id: i64) -> ChatInfo {
|
||||
TestChatBuilder::new(title, id).build()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn create_test_message(content: &str, id: i64) -> MessageInfo {
|
||||
TestMessageBuilder::new(content, id).build()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn create_test_user(name: &str, id: i64) -> (i64, String) {
|
||||
(id, name.to_string())
|
||||
}
|
||||
|
||||
/// Хелпер для создания профиля
|
||||
#[allow(dead_code)]
|
||||
pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo {
|
||||
ProfileInfo {
|
||||
chat_id: ChatId::new(chat_id),
|
||||
title: title.to_string(),
|
||||
username: None,
|
||||
bio: None,
|
||||
phone_number: None,
|
||||
chat_type: "Личный чат".to_string(),
|
||||
member_count: None,
|
||||
description: None,
|
||||
invite_link: None,
|
||||
is_group: false,
|
||||
online_status: None,
|
||||
}
|
||||
}
|
||||
172
crates/tele-core/src/types.rs
Normal file
172
crates/tele-core/src/types.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Type-safe ID wrappers to prevent mixing up different ID types.
|
||||
//!
|
||||
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
/// Chat identifier
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ChatId(pub i64);
|
||||
|
||||
impl ChatId {
|
||||
pub fn new(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for ChatId {
|
||||
fn from(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChatId> for i64 {
|
||||
fn from(id: ChatId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ChatId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message identifier
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct MessageId(pub i64);
|
||||
|
||||
impl MessageId {
|
||||
pub fn new(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for MessageId {
|
||||
fn from(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MessageId> for i64 {
|
||||
fn from(id: MessageId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MessageId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// User identifier
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct UserId(pub i64);
|
||||
|
||||
impl UserId {
|
||||
pub fn new(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
|
||||
pub fn as_i64(&self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for UserId {
|
||||
fn from(id: i64) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserId> for i64 {
|
||||
fn from(id: UserId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chat_id() {
|
||||
let id = ChatId::new(123);
|
||||
assert_eq!(id.as_i64(), 123);
|
||||
assert_eq!(i64::from(id), 123);
|
||||
|
||||
let id2: ChatId = 456.into();
|
||||
assert_eq!(id2.0, 456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_id() {
|
||||
let id = MessageId::new(789);
|
||||
assert_eq!(id.as_i64(), 789);
|
||||
assert_eq!(i64::from(id), 789);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_id() {
|
||||
let id = UserId::new(111);
|
||||
assert_eq!(id.as_i64(), 111);
|
||||
assert_eq!(i64::from(id), 111);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_safety() {
|
||||
// Type safety is enforced at compile time
|
||||
// The following would not compile:
|
||||
// let chat_id = ChatId::new(1);
|
||||
// let message_id = MessageId::new(1);
|
||||
// if chat_id == message_id { } // ERROR: mismatched types
|
||||
|
||||
// Runtime values can be the same, but types are different
|
||||
let chat_id = ChatId::new(1);
|
||||
let message_id = MessageId::new(1);
|
||||
assert_eq!(chat_id.as_i64(), 1);
|
||||
assert_eq!(message_id.as_i64(), 1);
|
||||
// But they cannot be compared directly due to type safety
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display() {
|
||||
let chat_id = ChatId::new(123);
|
||||
assert_eq!(format!("{}", chat_id), "123");
|
||||
|
||||
let message_id = MessageId::new(456);
|
||||
assert_eq!(format!("{}", message_id), "456");
|
||||
|
||||
let user_id = UserId::new(789);
|
||||
assert_eq!(format!("{}", user_id), "789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash_map() {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
map.insert(ChatId::new(1), "chat1");
|
||||
map.insert(ChatId::new(2), "chat2");
|
||||
|
||||
assert_eq!(map.get(&ChatId::new(1)), Some(&"chat1"));
|
||||
assert_eq!(map.get(&ChatId::new(2)), Some(&"chat2"));
|
||||
assert_eq!(map.get(&ChatId::new(3)), None);
|
||||
}
|
||||
}
|
||||
9
crates/tele-core/src/utils.rs
Normal file
9
crates/tele-core/src/utils.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use chrono::{DateTime, Local, NaiveDate, Utc};
|
||||
|
||||
pub fn get_day(timestamp: i32) -> i64 {
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
|
||||
let msg_day = DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||
.unwrap_or(epoch);
|
||||
msg_day.signed_duration_since(epoch).num_days()
|
||||
}
|
||||
66
crates/tele-tui/Cargo.toml
Normal file
66
crates/tele-tui/Cargo.toml
Normal 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
|
||||
88
crates/tele-tui/benches/format_markdown.rs
Normal file
88
crates/tele-tui/benches/format_markdown.rs
Normal 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);
|
||||
38
crates/tele-tui/benches/formatting.rs
Normal file
38
crates/tele-tui/benches/formatting.rs
Normal 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);
|
||||
43
crates/tele-tui/benches/group_messages.rs
Normal file
43
crates/tele-tui/benches/group_messages.rs
Normal 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
38
crates/tele-tui/build.rs
Normal 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()
|
||||
}
|
||||
122
crates/tele-tui/src/accounts/lock.rs
Normal file
122
crates/tele-tui/src/accounts/lock.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
202
crates/tele-tui/src/accounts/manager.rs
Normal file
202
crates/tele-tui/src/accounts/manager.rs
Normal 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)
|
||||
}
|
||||
15
crates/tele-tui/src/accounts/mod.rs
Normal file
15
crates/tele-tui/src/accounts/mod.rs
Normal 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};
|
||||
147
crates/tele-tui/src/accounts/profile.rs
Normal file
147
crates/tele-tui/src/accounts/profile.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
87
crates/tele-tui/src/app/auth_state.rs
Normal file
87
crates/tele-tui/src/app/auth_state.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
327
crates/tele-tui/src/app/chat_filter.rs
Normal file
327
crates/tele-tui/src/app/chat_filter.rs
Normal 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
|
||||
}
|
||||
}
|
||||
195
crates/tele-tui/src/app/chat_list_state.rs
Normal file
195
crates/tele-tui/src/app/chat_list_state.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
160
crates/tele-tui/src/app/chat_state.rs
Normal file
160
crates/tele-tui/src/app/chat_state.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
247
crates/tele-tui/src/app/compose_state.rs
Normal file
247
crates/tele-tui/src/app/compose_state.rs
Normal 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 ");
|
||||
}
|
||||
}
|
||||
512
crates/tele-tui/src/app/message_service.rs
Normal file
512
crates/tele-tui/src/app/message_service.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
277
crates/tele-tui/src/app/message_view_state.rs
Normal file
277
crates/tele-tui/src/app/message_view_state.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
117
crates/tele-tui/src/app/methods/compose.rs
Normal file
117
crates/tele-tui/src/app/methods/compose.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
175
crates/tele-tui/src/app/methods/messages.rs
Normal file
175
crates/tele-tui/src/app/methods/messages.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
25
crates/tele-tui/src/app/methods/mod.rs
Normal file
25
crates/tele-tui/src/app/methods/mod.rs
Normal 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;
|
||||
266
crates/tele-tui/src/app/methods/modal.rs
Normal file
266
crates/tele-tui/src/app/methods/modal.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
148
crates/tele-tui/src/app/methods/navigation.rs
Normal file
148
crates/tele-tui/src/app/methods/navigation.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
165
crates/tele-tui/src/app/methods/search.rs
Normal file
165
crates/tele-tui/src/app/methods/search.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
635
crates/tele-tui/src/app/mod.rs
Normal file
635
crates/tele-tui/src/app/mod.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
6
crates/tele-tui/src/app/state.rs
Normal file
6
crates/tele-tui/src/app/state.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum AppScreen {
|
||||
Loading,
|
||||
Auth,
|
||||
Main,
|
||||
}
|
||||
128
crates/tele-tui/src/app/ui_state.rs
Normal file
128
crates/tele-tui/src/app/ui_state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
155
crates/tele-tui/src/audio/cache.rs
Normal file
155
crates/tele-tui/src/audio/cache.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
11
crates/tele-tui/src/audio/mod.rs
Normal file
11
crates/tele-tui/src/audio/mod.rs
Normal 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;
|
||||
205
crates/tele-tui/src/audio/player.rs
Normal file
205
crates/tele-tui/src/audio/player.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
182
crates/tele-tui/src/bin/tele-tui-test-fixture.rs
Normal file
182
crates/tele-tui/src/bin/tele-tui-test-fixture.rs
Normal 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()]
|
||||
}
|
||||
556
crates/tele-tui/src/config/keybindings.rs
Normal file
556
crates/tele-tui/src/config/keybindings.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
197
crates/tele-tui/src/config/loader.rs
Normal file
197
crates/tele-tui/src/config/loader.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
401
crates/tele-tui/src/config/mod.rs
Normal file
401
crates/tele-tui/src/config/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
80
crates/tele-tui/src/config/validation.rs
Normal file
80
crates/tele-tui/src/config/validation.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
90
crates/tele-tui/src/constants.rs
Normal file
90
crates/tele-tui/src/constants.rs
Normal 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;
|
||||
329
crates/tele-tui/src/formatting.rs
Normal file
329
crates/tele-tui/src/formatting.rs
Normal 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 ¤t_style {
|
||||
Some(prev_style) if styles_equal(prev_style, style) => {
|
||||
current_text.push(*ch);
|
||||
}
|
||||
_ => {
|
||||
if !current_text.is_empty() {
|
||||
if let Some(prev_style) = ¤t_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); // Нет пересечений
|
||||
}
|
||||
}
|
||||
109
crates/tele-tui/src/input/auth.rs
Normal file
109
crates/tele-tui/src/input/auth.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
458
crates/tele-tui/src/input/handlers/chat.rs
Normal file
458
crates/tele-tui/src/input/handlers/chat.rs
Normal 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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
328
crates/tele-tui/src/input/handlers/chat/media.rs
Normal file
328
crates/tele-tui/src/input/handlers/chat/media.rs
Normal file
@@ -0,0 +1,328 @@
|
||||
//! Media actions for the open chat input handler.
|
||||
|
||||
use crate::app::methods::messages::MessageMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Обработка команды ViewImage — только фото.
|
||||
pub(super) async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if msg.has_photo() {
|
||||
#[cfg(feature = "images")]
|
||||
handle_view_image(app).await;
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||
}
|
||||
} else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Space: play/pause toggle для голосовых сообщений.
|
||||
pub(super) async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
if let Some(ref mut playback) = app.playback_state {
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match playback.status {
|
||||
PlaybackStatus::Playing => {
|
||||
player.pause();
|
||||
playback.status = PlaybackStatus::Paused;
|
||||
app.last_playback_tick = None;
|
||||
app.status_message = Some("⏸ Пауза".to_string());
|
||||
}
|
||||
PlaybackStatus::Paused => {
|
||||
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||
if player.resume_from(resume_pos).is_ok() {
|
||||
playback.position = resume_pos;
|
||||
} else {
|
||||
player.resume();
|
||||
}
|
||||
playback.status = PlaybackStatus::Playing;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
if msg.has_voice() {
|
||||
handle_play_voice(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek голосового сообщения на delta секунд.
|
||||
pub(super) fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
let Some(ref mut playback) = app.playback_state else {
|
||||
return;
|
||||
};
|
||||
let Some(ref player) = app.audio_player else {
|
||||
return;
|
||||
};
|
||||
|
||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||
|
||||
if was_playing || was_paused {
|
||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||
|
||||
if was_playing {
|
||||
if player.resume_from(new_position).is_ok() {
|
||||
playback.position = new_position;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
player.stop();
|
||||
playback.position = new_position;
|
||||
}
|
||||
|
||||
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||
|
||||
if !app.config().images.show_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_photo() {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(photo) = msg.photo_info() else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
};
|
||||
let msg_id = msg.id();
|
||||
let file_id = photo.file_id;
|
||||
let photo_width = photo.width;
|
||||
let photo_height = photo.height;
|
||||
let download_state = photo.download_state.clone();
|
||||
|
||||
match download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
||||
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
||||
file_id,
|
||||
message_id: msg_id,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
if app.photo_download_rx.is_none() {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.photo_download_rx = Some(rx);
|
||||
let client_id = app.td_client.client_id();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(f))
|
||||
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
||||
{
|
||||
Ok(f.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(_) => {
|
||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state =
|
||||
PhotoDownloadState::Downloaded(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
path: &str,
|
||||
voice: &crate::tdlib::VoiceInfo,
|
||||
msg: &crate::tdlib::MessageInfo,
|
||||
) {
|
||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match player.play(path) {
|
||||
Ok(_) => {
|
||||
app.playback_state = Some(PlaybackState {
|
||||
message_id: msg.id(),
|
||||
status: PlaybackStatus::Playing,
|
||||
position: 0.0,
|
||||
duration: voice.duration as f32,
|
||||
volume: player.volume(),
|
||||
});
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::VoiceDownloadState;
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_voice() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(voice) = msg.voice_info() else {
|
||||
app.status_message = Some("Сообщение не содержит голосовое".to_string());
|
||||
return;
|
||||
};
|
||||
let file_id = voice.file_id;
|
||||
|
||||
match &voice.download_state {
|
||||
VoiceDownloadState::Downloaded(path) => {
|
||||
use std::path::Path;
|
||||
let audio_path = if Path::new(path).exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
let with_oga = format!("{}.oga", path);
|
||||
if Path::new(&with_oga).exists() {
|
||||
with_oga
|
||||
} else {
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
if let Some(stem) = Path::new(path).file_name() {
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_name = entry.file_name();
|
||||
if entry_name
|
||||
.to_string_lossy()
|
||||
.starts_with(&stem.to_string_lossy().to_string())
|
||||
{
|
||||
let found_path = entry.path().to_string_lossy().to_string();
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(
|
||||
&file_id.to_string(),
|
||||
Path::new(&found_path),
|
||||
);
|
||||
}
|
||||
return handle_play_voice_from_path(
|
||||
app,
|
||||
&found_path,
|
||||
voice,
|
||||
&msg,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
||||
}
|
||||
VoiceDownloadState::Downloading => {
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
}
|
||||
VoiceDownloadState::NotDownloaded => {
|
||||
let cache_key = file_id.to_string();
|
||||
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||
let path_str = cached_path.to_string_lossy().to_string();
|
||||
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
||||
return;
|
||||
}
|
||||
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
match app.td_client.download_voice_note(file_id).await {
|
||||
Ok(path) => {
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceDownloadState::Error(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
76
crates/tele-tui/src/input/handlers/chat_list.rs
Normal file
76
crates/tele-tui/src/input/handlers/chat_list.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
295
crates/tele-tui/src/input/handlers/chat_loader.rs
Normal file
295
crates/tele-tui/src/input/handlers/chat_loader.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
101
crates/tele-tui/src/input/handlers/clipboard.rs
Normal file
101
crates/tele-tui/src/input/handlers/clipboard.rs
Normal 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
|
||||
}
|
||||
85
crates/tele-tui/src/input/handlers/compose.rs
Normal file
85
crates/tele-tui/src/input/handlers/compose.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
crates/tele-tui/src/input/handlers/global.rs
Normal file
102
crates/tele-tui/src/input/handlers/global.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
crates/tele-tui/src/input/handlers/mod.rs
Normal file
45
crates/tele-tui/src/input/handlers/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
crates/tele-tui/src/input/handlers/modal.rs
Normal file
13
crates/tele-tui/src/input/handlers/modal.rs
Normal 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;
|
||||
76
crates/tele-tui/src/input/handlers/modal/account.rs
Normal file
76
crates/tele-tui/src/input/handlers/modal/account.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
/// Обработка ввода в модалке переключения аккаунтов.
|
||||
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let Some(state) = &app.account_switcher else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
AccountSwitcherState::SelectAccount { .. } => match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.account_switcher_select_prev();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.account_switcher_select_next();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
app.account_switcher_confirm();
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.close_account_switcher();
|
||||
}
|
||||
_ => match key.code {
|
||||
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
||||
app.account_switcher_start_add();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
},
|
||||
AccountSwitcherState::AddAccount { .. } => match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.account_switcher_back();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.account_switcher_confirm_add();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
if *cursor_position > 0 {
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.remove(*cursor_position - 1);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position -= 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.insert(*cursor_position, c);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position += 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
52
crates/tele-tui/src/input/handlers/modal/delete.rs
Normal file
52
crates/tele-tui/src/input/handlers/modal/delete.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use crate::app::{App, ChatState};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::ChatId;
|
||||
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка модалки подтверждения удаления сообщения.
|
||||
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match handle_yes_no(key.code) {
|
||||
Some(true) => {
|
||||
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
let can_delete_for_all = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == msg_id)
|
||||
.map(|m| m.can_be_deleted_for_all_users())
|
||||
.unwrap_or(false);
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.delete_messages(
|
||||
ChatId::new(chat_id),
|
||||
vec![msg_id],
|
||||
can_delete_for_all,
|
||||
),
|
||||
"Таймаут удаления",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
messages.retain(|m| m.id() != msg_id);
|
||||
});
|
||||
app.chat_state = ChatState::Normal;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.chat_state = ChatState::Normal;
|
||||
}
|
||||
Some(false) => {
|
||||
app.chat_state = ChatState::Normal;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
32
crates/tele-tui/src/input/handlers/modal/pinned.rs
Normal file
32
crates/tele-tui/src/input/handlers/modal/pinned.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::App;
|
||||
use crate::input::handlers::scroll_to_message;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::MessageId;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Обработка режима просмотра закреплённых сообщений.
|
||||
pub async fn handle_pinned_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_pinned();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_pinned();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||
scroll_to_message(app, MessageId::new(msg_id));
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
136
crates/tele-tui/src/input/handlers/modal/profile.rs
Normal file
136
crates/tele-tui/src/input/handlers/modal/profile.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::methods::navigation::NavigationMethods;
|
||||
use crate::app::App;
|
||||
use crate::input::handlers::get_available_actions_count;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка режима профиля пользователя/чата.
|
||||
pub async fn handle_profile_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||
if confirmation_step > 0 {
|
||||
match handle_yes_no(key.code) {
|
||||
Some(true) => {
|
||||
if confirmation_step == 1 {
|
||||
app.show_leave_group_final_confirmation();
|
||||
} else if confirmation_step == 2 {
|
||||
if let Some(chat_id) = app.selected_chat_id {
|
||||
let leave_result = app.td_client.leave_chat(chat_id).await;
|
||||
match leave_result {
|
||||
Ok(_) => {
|
||||
app.status_message = Some("Вы вышли из группы".to_string());
|
||||
app.exit_profile_mode();
|
||||
app.close_chat();
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.cancel_leave_group();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
app.cancel_leave_group();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_profile_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_profile_action();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let Some(profile) = app.get_profile_info() {
|
||||
let max_actions = get_available_actions_count(profile);
|
||||
app.select_next_profile_action(max_actions);
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
let Some(profile) = app.get_profile_info() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let actions = get_available_actions_count(profile);
|
||||
let action_index = app.get_selected_profile_action().unwrap_or(0);
|
||||
if action_index >= actions {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut current_idx = 0;
|
||||
|
||||
if let Some(username) = &profile.username {
|
||||
if action_index == current_idx {
|
||||
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
||||
#[cfg(feature = "url-open")]
|
||||
{
|
||||
match open::that(&url) {
|
||||
Ok(_) => {
|
||||
app.status_message = Some(format!("Открыто: {}", url));
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message =
|
||||
Some(format!("Ошибка открытия браузера: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "url-open"))]
|
||||
{
|
||||
app.error_message = Some(
|
||||
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
current_idx += 1;
|
||||
}
|
||||
|
||||
if action_index == current_idx {
|
||||
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
|
||||
return;
|
||||
}
|
||||
current_idx += 1;
|
||||
|
||||
if profile.is_group && action_index == current_idx {
|
||||
app.show_leave_group_confirmation();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка Ctrl+I для открытия профиля чата/пользователя.
|
||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
app.status_message = Some("Загрузка профиля...".to_string());
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.get_profile_info(chat_id),
|
||||
"Таймаут загрузки профиля",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(profile) => {
|
||||
app.enter_profile_mode(profile);
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
crates/tele-tui/src/input/handlers/modal/reactions.rs
Normal file
54
crates/tele-tui/src/input/handlers/modal/reactions.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Обработка режима выбора реакции (emoji picker).
|
||||
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveLeft) => {
|
||||
app.select_previous_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Some(crate::config::Command::MoveRight) => {
|
||||
app.select_next_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
|
||||
&mut app.chat_state
|
||||
{
|
||||
if *selected_index >= 8 {
|
||||
*selected_index = selected_index.saturating_sub(8);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let crate::app::ChatState::ReactionPicker {
|
||||
selected_index,
|
||||
available_reactions,
|
||||
..
|
||||
} = &mut app.chat_state
|
||||
{
|
||||
let new_index = *selected_index + 8;
|
||||
if new_index < available_reactions.len() {
|
||||
*selected_index = new_index;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
crate::input::handlers::chat::send_reaction(app).await;
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_reaction_picker_mode();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
21
crates/tele-tui/src/input/handlers/profile.rs
Normal file
21
crates/tele-tui/src/input/handlers/profile.rs
Normal 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
|
||||
}
|
||||
136
crates/tele-tui/src/input/handlers/search.rs
Normal file
136
crates/tele-tui/src/input/handlers/search.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
327
crates/tele-tui/src/input/main_input.rs
Normal file
327
crates/tele-tui/src/input/main_input.rs
Normal 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());
|
||||
}
|
||||
10
crates/tele-tui/src/input/mod.rs
Normal file
10
crates/tele-tui/src/input/mod.rs
Normal 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;
|
||||
20
crates/tele-tui/src/lib.rs
Normal file
20
crates/tele-tui/src/lib.rs
Normal 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
540
crates/tele-tui/src/main.rs
Normal 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(¬ifications_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
|
||||
}
|
||||
112
crates/tele-tui/src/media/cache.rs
Normal file
112
crates/tele-tui/src/media/cache.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
125
crates/tele-tui/src/media/image_renderer.rs
Normal file
125
crates/tele-tui/src/media/image_renderer.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
9
crates/tele-tui/src/media/mod.rs
Normal file
9
crates/tele-tui/src/media/mod.rs
Normal 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;
|
||||
362
crates/tele-tui/src/notifications.rs
Normal file
362
crates/tele-tui/src/notifications.rs
Normal 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!"
|
||||
);
|
||||
}
|
||||
}
|
||||
288
crates/tele-tui/src/test_support/app_builder.rs
Normal file
288
crates/tele-tui/src/test_support/app_builder.rs
Normal 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
|
||||
}
|
||||
}
|
||||
6
crates/tele-tui/src/test_support/mod.rs
Normal file
6
crates/tele-tui/src/test_support/mod.rs
Normal 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};
|
||||
144
crates/tele-tui/src/test_support/snapshot_utils.rs
Normal file
144
crates/tele-tui/src/test_support/snapshot_utils.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
137
crates/tele-tui/src/ui/auth.rs
Normal file
137
crates/tele-tui/src/ui/auth.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
107
crates/tele-tui/src/ui/chat_list.rs
Normal file
107
crates/tele-tui/src/ui/chat_list.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
78
crates/tele-tui/src/ui/components/chat_list_item.rs
Normal file
78
crates/tele-tui/src/ui/components/chat_list_item.rs
Normal 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)
|
||||
}
|
||||
104
crates/tele-tui/src/ui/components/emoji_picker.rs
Normal file
104
crates/tele-tui/src/ui/components/emoji_picker.rs
Normal 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);
|
||||
}
|
||||
50
crates/tele-tui/src/ui/components/input_field.rs
Normal file
50
crates/tele-tui/src/ui/components/input_field.rs
Normal 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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user