fixes
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
This commit is contained in:
209
src/accounts/manager.rs
Normal file
209
src/accounts/manager.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! 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)
|
||||
}
|
||||
11
src/accounts/mod.rs
Normal file
11
src/accounts/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! 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 manager;
|
||||
pub mod profile;
|
||||
|
||||
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
|
||||
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
|
||||
147
src/accounts/profile.rs
Normal file
147
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"));
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
|
||||
self.cursor_position = 0;
|
||||
self.message_scroll_offset = 0;
|
||||
self.last_typing_sent = None;
|
||||
self.pending_chat_init = None;
|
||||
// Сбрасываем состояние чата в нормальный режим
|
||||
self.chat_state = ChatState::Normal;
|
||||
self.input_mode = InputMode::Normal;
|
||||
|
||||
159
src/app/mod.rs
159
src/app/mod.rs
@@ -13,9 +13,28 @@ pub use chat_state::{ChatState, InputMode};
|
||||
pub use state::AppScreen;
|
||||
pub use methods::*;
|
||||
|
||||
use crate::accounts::AccountProfile;
|
||||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||||
use crate::types::ChatId;
|
||||
use ratatui::widgets::ListState;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -44,7 +63,7 @@ use ratatui::widgets::ListState;
|
||||
/// use tele_tui::config::Config;
|
||||
///
|
||||
/// let config = Config::default();
|
||||
/// let mut app = App::new(config);
|
||||
/// let mut app = App::new(config, std::path::PathBuf::from("tdlib_data"));
|
||||
///
|
||||
/// // Navigate through chats
|
||||
/// app.next_chat();
|
||||
@@ -102,6 +121,15 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
||||
#[cfg(feature = "images")]
|
||||
pub last_image_render_time: Option<std::time::Instant>,
|
||||
// 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, pinned, photos) after fast open
|
||||
pub pending_chat_init: Option<ChatId>,
|
||||
// Voice playback
|
||||
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
||||
pub audio_player: Option<crate::audio::AudioPlayer>,
|
||||
@@ -164,6 +192,11 @@ impl<T: TdClientTrait> App<T> {
|
||||
search_query: String::new(),
|
||||
needs_redraw: true,
|
||||
last_typing_sent: None,
|
||||
// Account switcher
|
||||
account_switcher: None,
|
||||
current_account_name: "default".to_string(),
|
||||
pending_account_switch: None,
|
||||
pending_chat_init: None,
|
||||
#[cfg(feature = "images")]
|
||||
image_cache,
|
||||
#[cfg(feature = "images")]
|
||||
@@ -210,6 +243,123 @@ impl<T: TdClientTrait> App<T> {
|
||||
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
|
||||
@@ -425,16 +575,17 @@ 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.
|
||||
/// 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) -> App<TdClient> {
|
||||
App::with_client(config, TdClient::new())
|
||||
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
|
||||
App::with_client(config, TdClient::new(db_path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,15 +591,20 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
}
|
||||
|
||||
let photo = msg.photo_info().unwrap();
|
||||
let msg_id = msg.id();
|
||||
let file_id = photo.file_id;
|
||||
let photo_width = photo.width;
|
||||
let photo_height = photo.height;
|
||||
let download_state = photo.download_state.clone();
|
||||
|
||||
match &photo.download_state {
|
||||
match download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
// Открываем модальное окно
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg.id(),
|
||||
photo_path: path.clone(),
|
||||
photo_width: photo.width,
|
||||
photo_height: photo.height,
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
@@ -607,10 +612,73 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded => {
|
||||
app.status_message = Some("Фото не загружено".to_string());
|
||||
// Скачиваем фото и открываем
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
// Обновляем состояние загрузки в сообщении
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state =
|
||||
PhotoDownloadState::Downloaded(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Открываем модалку
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state =
|
||||
PhotoDownloadState::Error(e.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
PhotoDownloadState::Error(_) => {
|
||||
// Повторная попытка загрузки
|
||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state =
|
||||
PhotoDownloadState::Downloaded(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::app::InputMode;
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore};
|
||||
use crate::utils::{with_timeout, with_timeout_msg};
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -75,24 +75,21 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Открывает чат и загружает все необходимые данные.
|
||||
/// Открывает чат и загружает последние сообщения (быстро).
|
||||
///
|
||||
/// Выполняет:
|
||||
/// - Загрузку истории сообщений (с timeout)
|
||||
/// - Установку current_chat_id (после загрузки, чтобы избежать race condition)
|
||||
/// - Загрузку reply info (с timeout)
|
||||
/// - Загрузку закреплённого сообщения (с timeout)
|
||||
/// - Загрузку черновика
|
||||
/// Загружает только 50 последних сообщений для мгновенного отображения.
|
||||
/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init`
|
||||
/// и выполняются на следующем тике main loop.
|
||||
///
|
||||
/// При ошибке устанавливает 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(30),
|
||||
app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX),
|
||||
Duration::from_secs(10),
|
||||
app.td_client.get_chat_history(ChatId::new(chat_id), 50),
|
||||
"Таймаут загрузки сообщений",
|
||||
)
|
||||
.await
|
||||
@@ -119,27 +116,16 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
|
||||
// Это предотвращает race condition с Update::NewMessage
|
||||
app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
|
||||
|
||||
// Загружаем недостающие reply info (игнорируем ошибки)
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.fetch_missing_reply_info(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(2),
|
||||
app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Загружаем черновик
|
||||
// Загружаем черновик (локальная операция, мгновенно)
|
||||
app.load_draft();
|
||||
app.status_message = None;
|
||||
|
||||
// Vim mode: Normal + MessageSelection по умолчанию
|
||||
// Показываем чат СРАЗУ
|
||||
app.status_message = None;
|
||||
app.input_mode = InputMode::Normal;
|
||||
app.start_message_selection();
|
||||
|
||||
// Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop
|
||||
app.pending_chat_init = Some(ChatId::new(chat_id));
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
|
||||
@@ -58,6 +58,11 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
handle_pinned_messages(app).await;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('a') if has_ctrl => {
|
||||
// Ctrl+A - переключение аккаунтов
|
||||
app.open_account_switcher();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,114 @@
|
||||
//! Modal dialog handlers
|
||||
//!
|
||||
//! Handles keyboard input for modal dialogs, including:
|
||||
//! - Account switcher (global overlay)
|
||||
//! - Delete confirmation
|
||||
//! - Reaction picker (emoji selector)
|
||||
//! - Pinned messages view
|
||||
//! - Profile information modal
|
||||
|
||||
use crate::app::App;
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
|
||||
use crate::input::handlers::get_available_actions_count;
|
||||
use super::scroll_to_message;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка ввода в модалке переключения аккаунтов
|
||||
///
|
||||
/// **SelectAccount mode:**
|
||||
/// - j/k (MoveUp/MoveDown) — навигация по списку
|
||||
/// - Enter — выбор аккаунта или переход к добавлению
|
||||
/// - a/ф — быстрое добавление аккаунта
|
||||
/// - Esc — закрыть модалку
|
||||
///
|
||||
/// **AddAccount mode:**
|
||||
/// - Char input → ввод имени
|
||||
/// - Backspace → удалить символ
|
||||
/// - Enter → создать аккаунт
|
||||
/// - Esc → назад к списку
|
||||
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let Some(state) = &app.account_switcher else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
AccountSwitcherState::SelectAccount { .. } => {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.account_switcher_select_prev();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.account_switcher_select_next();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
app.account_switcher_confirm();
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.close_account_switcher();
|
||||
}
|
||||
_ => {
|
||||
// Raw key check for 'a'/'ф' shortcut
|
||||
match key.code {
|
||||
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
||||
app.account_switcher_start_add();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountSwitcherState::AddAccount { .. } => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.account_switcher_back();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.account_switcher_confirm_add();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
if *cursor_position > 0 {
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.remove(*cursor_position - 1);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position -= 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.insert(*cursor_position, c);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position += 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима профиля пользователя/чата
|
||||
///
|
||||
/// Обрабатывает:
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::tdlib::TdClientTrait;
|
||||
use crate::input::handlers::{
|
||||
handle_global_commands,
|
||||
modal::{
|
||||
handle_account_switcher,
|
||||
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
|
||||
handle_reaction_picker_mode, handle_pinned_mode,
|
||||
},
|
||||
@@ -78,6 +79,12 @@ fn handle_escape_insert<T: TdClientTrait>(app: &mut App<T>) {
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! Library interface exposing modules for integration testing.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod app;
|
||||
pub mod audio;
|
||||
pub mod config;
|
||||
|
||||
154
src/main.rs
154
src/main.rs
@@ -1,3 +1,4 @@
|
||||
mod accounts;
|
||||
mod app;
|
||||
mod audio;
|
||||
mod config;
|
||||
@@ -31,6 +32,21 @@ 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" {
|
||||
if i + 1 < args.len() {
|
||||
return Some(args[i + 1].clone());
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), io::Error> {
|
||||
// Загружаем переменные окружения из .env
|
||||
@@ -48,6 +64,24 @@ async fn main() -> Result<(), io::Error> {
|
||||
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||||
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);
|
||||
|
||||
// Отключаем логи TDLib ДО создания клиента
|
||||
disable_tdlib_logs();
|
||||
|
||||
@@ -66,18 +100,20 @@ async fn main() -> Result<(), io::Error> {
|
||||
panic_hook(info);
|
||||
}));
|
||||
|
||||
// Create app state
|
||||
let mut app = App::new(config);
|
||||
// Create app state with account-specific db_path
|
||||
let mut app = App::new(config, db_path);
|
||||
app.current_account_name = account_name;
|
||||
|
||||
// Запускаем инициализацию 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 {
|
||||
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
||||
false, // use_test_dc
|
||||
"tdlib_data".to_string(), // database_directory
|
||||
db_path_str, // database_directory
|
||||
"".to_string(), // files_directory
|
||||
"".to_string(), // database_encryption_key
|
||||
true, // use_file_database
|
||||
@@ -233,12 +269,23 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match app.screen {
|
||||
AppScreen::Loading => {
|
||||
// В состоянии загрузки игнорируем ввод
|
||||
// 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,
|
||||
}
|
||||
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||
AppScreen::Main => handle_main_input(app, key).await,
|
||||
}
|
||||
|
||||
// Любой ввод требует перерисовки
|
||||
@@ -251,6 +298,97 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending chat initialization (reply info, pinned, photos)
|
||||
if let Some(chat_id) = app.pending_chat_init.take() {
|
||||
// Загружаем недостающие reply info (игнорируем ошибки)
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.fetch_missing_reply_info(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(2),
|
||||
app.td_client.load_current_pinned_message(chat_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Авто-загрузка фото (последние 30 сообщений)
|
||||
#[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(30)
|
||||
.filter_map(|msg| {
|
||||
msg.photo_info().and_then(|p| {
|
||||
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
||||
.then_some(p.file_id)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for file_id in &photo_file_ids {
|
||||
if let Ok(Ok(path)) = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.download_file(*file_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == *file_id {
|
||||
photo.download_state =
|
||||
PhotoDownloadState::Downloaded(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
// Check pending account switch
|
||||
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() {
|
||||
// 1. Stop playback
|
||||
app.stop_playback();
|
||||
|
||||
// 2. 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;
|
||||
}
|
||||
|
||||
// 3. Reset app state
|
||||
app.current_account_name = account_name;
|
||||
app.screen = AppScreen::Loading;
|
||||
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.account_switcher = None;
|
||||
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{
|
||||
ChatList, ConnectionState, Update, UserStatus,
|
||||
Chat as TdChat
|
||||
@@ -32,7 +33,7 @@ use crate::notifications::NotificationManager;
|
||||
/// ```ignore
|
||||
/// use tele_tui::tdlib::TdClient;
|
||||
///
|
||||
/// let mut client = TdClient::new();
|
||||
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
|
||||
///
|
||||
/// // Start authorization
|
||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||
@@ -45,6 +46,7 @@ use crate::notifications::NotificationManager;
|
||||
pub struct TdClient {
|
||||
pub api_id: i32,
|
||||
pub api_hash: String,
|
||||
pub db_path: PathBuf,
|
||||
client_id: i32,
|
||||
|
||||
// Менеджеры (делегируем им функциональность)
|
||||
@@ -71,7 +73,7 @@ impl TdClient {
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `TdClient` instance ready for authentication.
|
||||
pub fn new() -> Self {
|
||||
pub fn new(db_path: PathBuf) -> Self {
|
||||
// Пробуем загрузить credentials из Config (файл или env)
|
||||
let (api_id, api_hash) = crate::config::Config::load_credentials()
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -89,6 +91,7 @@ impl TdClient {
|
||||
Self {
|
||||
api_id,
|
||||
api_hash,
|
||||
db_path,
|
||||
client_id,
|
||||
auth: AuthManager::new(client_id),
|
||||
chat_manager: ChatManager::new(client_id),
|
||||
@@ -624,6 +627,49 @@ impl TdClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(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 {
|
||||
let _ = 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;
|
||||
});
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::r#trait::TdClientTrait;
|
||||
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
#[async_trait]
|
||||
@@ -278,6 +279,11 @@ impl TdClientTrait for TdClient {
|
||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
||||
}
|
||||
|
||||
// ============ Account switching ============
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||
TdClient::recreate_client(self, db_path).await
|
||||
}
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, update: Update) {
|
||||
// Delegate to the real implementation
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{ChatAction, Update};
|
||||
|
||||
use super::ChatInfo;
|
||||
@@ -127,6 +128,13 @@ pub trait TdClientTrait: Send {
|
||||
// ============ Notification methods ============
|
||||
fn sync_notification_muted_chats(&mut self);
|
||||
|
||||
// ============ Account switching ============
|
||||
/// Recreates the client with a new database path (for account switching).
|
||||
///
|
||||
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
||||
/// For FakeTdClient: no-op.
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, update: Update);
|
||||
}
|
||||
|
||||
@@ -19,22 +19,29 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
NetworkState::Updating => "⏳ Обновление... | ",
|
||||
};
|
||||
|
||||
// Account indicator (shown if not "default")
|
||||
let account_indicator = if app.current_account_name != "default" {
|
||||
format!("[{}] ", app.current_account_name)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let status = if let Some(msg) = &app.status_message {
|
||||
format!(" {}{} ", network_indicator, msg)
|
||||
format!(" {}{}{} ", account_indicator, network_indicator, msg)
|
||||
} else if let Some(err) = &app.error_message {
|
||||
format!(" {}Error: {} ", network_indicator, err)
|
||||
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
|
||||
} else if app.is_searching {
|
||||
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
|
||||
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
|
||||
} else if app.selected_chat_id.is_some() {
|
||||
let mode_str = match app.input_mode {
|
||||
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
||||
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
|
||||
};
|
||||
format!(" {}{} | Ctrl+C: Quit ", network_indicator, mode_str)
|
||||
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
|
||||
} else {
|
||||
format!(
|
||||
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||
network_indicator
|
||||
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||
account_indicator, network_indicator
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
||||
AppScreen::Auth => auth::render(f, app),
|
||||
AppScreen::Main => main_screen::render(f, app),
|
||||
}
|
||||
|
||||
// Global overlay: account switcher (renders on top of ANY screen)
|
||||
if app.account_switcher.is_some() {
|
||||
modals::render_account_switcher(f, area, app);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||
|
||||
210
src/ui/modals/account_switcher.rs
Normal file
210
src/ui/modals/account_switcher.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! Account switcher modal
|
||||
//!
|
||||
//! Renders a centered popup with account list (SelectAccount) or
|
||||
//! new account name input (AddAccount).
|
||||
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Renders the account switcher modal overlay.
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let Some(state) = &app.account_switcher else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
AccountSwitcherState::SelectAccount {
|
||||
accounts,
|
||||
selected_index,
|
||||
current_account,
|
||||
} => {
|
||||
render_select_account(f, area, accounts, *selected_index, current_account);
|
||||
}
|
||||
AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
} => {
|
||||
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_select_account(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
accounts: &[crate::accounts::AccountProfile],
|
||||
selected_index: usize,
|
||||
current_account: &str,
|
||||
) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (idx, account) in accounts.iter().enumerate() {
|
||||
let is_selected = idx == selected_index;
|
||||
let is_current = account.name == current_account;
|
||||
|
||||
let marker = if is_current { "● " } else { " " };
|
||||
let suffix = if is_current { " (текущий)" } else { "" };
|
||||
let display = format!(
|
||||
"{}{} ({}){}",
|
||||
marker, account.name, account.display_name, suffix
|
||||
);
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_current {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
|
||||
}
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ──────────────────────",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
// Add account item
|
||||
let add_selected = selected_index == accounts.len();
|
||||
let add_style = if add_selected {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
" + Добавить аккаунт",
|
||||
add_style,
|
||||
)));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Help bar
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||
Span::styled("Select", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" a ", Style::default().fg(Color::Cyan)),
|
||||
Span::styled("Add", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||
Span::styled("Close", Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
|
||||
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
|
||||
let content_height = (accounts.len() as u16) + 7;
|
||||
let height = content_height.min(area.height.saturating_sub(4));
|
||||
let width = 40u16.min(area.width.saturating_sub(4));
|
||||
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
let modal_area = Rect::new(x, y, width, height);
|
||||
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
let modal = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(" АККАУНТЫ ")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
|
||||
fn render_add_account(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
name_input: &str,
|
||||
_cursor_position: usize,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Input field
|
||||
let input_display = if name_input.is_empty() {
|
||||
Span::styled("_", Style::default().fg(Color::DarkGray))
|
||||
} else {
|
||||
Span::styled(
|
||||
format!("{}_", name_input),
|
||||
Style::default().fg(Color::White),
|
||||
)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
|
||||
input_display,
|
||||
]));
|
||||
|
||||
// Hint
|
||||
lines.push(Line::from(Span::styled(
|
||||
" (a-z, 0-9, -, _)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Error
|
||||
if let Some(err) = error {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", err),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
// Help bar
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||
Span::styled("Create", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||
Span::styled("Back", Style::default().fg(Color::DarkGray)),
|
||||
]));
|
||||
|
||||
let height = if error.is_some() { 10 } else { 8 };
|
||||
let height = (height as u16).min(area.height.saturating_sub(4));
|
||||
let width = 40u16.min(area.width.saturating_sub(4));
|
||||
|
||||
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||
let modal_area = Rect::new(x, y, width, height);
|
||||
|
||||
f.render_widget(Clear, modal_area);
|
||||
|
||||
let modal = Paragraph::new(lines).block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Cyan))
|
||||
.title(" НОВЫЙ АККАУНТ ")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
);
|
||||
|
||||
f.render_widget(modal, modal_area);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Modal dialog rendering modules
|
||||
//!
|
||||
//! Contains UI rendering for various modal dialogs:
|
||||
//! - account_switcher: Account switcher modal (global overlay)
|
||||
//! - delete_confirm: Delete confirmation modal
|
||||
//! - reaction_picker: Emoji reaction picker modal
|
||||
//! - search: Message search modal
|
||||
//! - pinned: Pinned messages viewer modal
|
||||
//! - image_viewer: Full-screen image viewer modal (images feature)
|
||||
|
||||
pub mod account_switcher;
|
||||
pub mod delete_confirm;
|
||||
pub mod reaction_picker;
|
||||
pub mod search;
|
||||
@@ -15,6 +17,7 @@ pub mod pinned;
|
||||
#[cfg(feature = "images")]
|
||||
pub mod image_viewer;
|
||||
|
||||
pub use account_switcher::render as render_account_switcher;
|
||||
pub use delete_confirm::render as render_delete_confirm;
|
||||
pub use reaction_picker::render as render_reaction_picker;
|
||||
pub use search::render as render_search;
|
||||
|
||||
Reference in New Issue
Block a user