From 356d2d3064b61b467f77e0dd5b8810e89ed08f46 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 27 Jan 2026 13:41:29 +0300 Subject: [PATCH] add account profile --- .serena/.gitignore | 1 + .serena/project.yml | 89 ++++++++++++++ Cargo.lock | 37 ++++++ Cargo.toml | 1 + DEVELOPMENT.md | 5 +- src/app/mod.rs | 69 +++++++++++ src/input/main_input.rs | 140 ++++++++++++++++++++++ src/tdlib/client.rs | 147 +++++++++++++++++++++++ src/tdlib/mod.rs | 1 + src/ui/chat_list.rs | 25 +--- src/ui/footer.rs | 2 +- src/ui/messages.rs | 8 ++ src/ui/mod.rs | 1 + src/ui/profile.rs | 259 ++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 28 +++++ 15 files changed, 787 insertions(+), 26 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 src/ui/profile.rs diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..33722ad --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,89 @@ +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# powershell python python_jedi r rego +# ruby ruby_solargraph rust scala swift +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# - csharp: Requires the presence of a .sln file in the project folder. +# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- rust + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in all projects +# same syntax as gitignore, so you can use * and ** +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "tele-tui" +included_optional_tools: [] diff --git a/Cargo.lock b/Cargo.lock index 9e70428..cb69bc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -989,6 +989,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1175,6 +1194,17 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.75" @@ -1254,6 +1284,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1878,6 +1914,7 @@ dependencies = [ "chrono", "crossterm", "dotenvy", + "open", "ratatui", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 2a2f65f..93ebb25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dotenvy = "0.15" chrono = "0.4" +open = "5.0" [build-dependencies] tdlib-rs = { version = "1.1", features = ["download-tdlib"] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8380470..af25ffd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -99,4 +99,7 @@ cargo run 5. Ждёт фидбек 6. Переходит к следующему этапу -``` \ No newline at end of file +``` + +## Работа с git +- никогда не добавляй себя в соавторов в тексте коммита \ No newline at end of file diff --git a/src/app/mod.rs b/src/app/mod.rs index 143c23a..5269a05 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,6 +66,15 @@ pub struct App { pub message_search_results: Vec, /// Индекс выбранного результата pub selected_search_result_index: usize, + // Profile mode + /// Режим просмотра профиля + pub is_profile_mode: bool, + /// Индекс выбранного действия в профиле + pub selected_profile_action: usize, + /// Шаг подтверждения выхода из группы (0 = не показано, 1 = первое, 2 = второе) + pub leave_group_confirmation_step: u8, + /// Информация профиля для отображения + pub profile_info: Option, } impl App { @@ -106,6 +115,10 @@ impl App { message_search_query: String::new(), message_search_results: Vec::new(), selected_search_result_index: 0, + is_profile_mode: false, + selected_profile_action: 0, + leave_group_confirmation_step: 0, + profile_info: None, } } @@ -537,4 +550,60 @@ impl App { self.cursor_position = self.message_input.chars().count(); } } + + // === Profile Mode === + + /// Проверить, активен ли режим профиля + pub fn is_profile_mode(&self) -> bool { + self.is_profile_mode + } + + /// Войти в режим профиля + pub fn enter_profile_mode(&mut self) { + self.is_profile_mode = true; + self.selected_profile_action = 0; + self.leave_group_confirmation_step = 0; + } + + /// Выйти из режима профиля + pub fn exit_profile_mode(&mut self) { + self.is_profile_mode = false; + self.selected_profile_action = 0; + self.leave_group_confirmation_step = 0; + self.profile_info = None; + } + + /// Выбрать предыдущее действие + pub fn select_previous_profile_action(&mut self) { + if self.selected_profile_action > 0 { + self.selected_profile_action -= 1; + } + } + + /// Выбрать следующее действие + pub fn select_next_profile_action(&mut self, max_actions: usize) { + if self.selected_profile_action < max_actions.saturating_sub(1) { + self.selected_profile_action += 1; + } + } + + /// Показать первое подтверждение выхода из группы + pub fn show_leave_group_confirmation(&mut self) { + self.leave_group_confirmation_step = 1; + } + + /// Показать второе подтверждение выхода из группы + pub fn show_leave_group_final_confirmation(&mut self) { + self.leave_group_confirmation_step = 2; + } + + /// Отменить подтверждение выхода из группы + pub fn cancel_leave_group(&mut self) { + self.leave_group_confirmation_step = 0; + } + + /// Получить текущий шаг подтверждения + pub fn get_leave_group_confirmation_step(&self) -> u8 { + self.leave_group_confirmation_step + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 874a5d8..7f60eba 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -56,9 +56,109 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } return; } + _ => {} } + // Режим профиля + if app.is_profile_mode() { + // Обработка подтверждения выхода из группы + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + match key.code { + KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { + 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(); + } + } + } + } + } + KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { + // Отмена + app.cancel_leave_group(); + } + _ => {} + } + return; + } + + // Обычная навигация по профилю + match key.code { + KeyCode::Esc => { + app.exit_profile_mode(); + } + KeyCode::Up => { + app.select_previous_profile_action(); + } + KeyCode::Down => { + if let Some(profile) = &app.profile_info { + let max_actions = get_available_actions_count(profile); + app.select_next_profile_action(max_actions); + } + } + KeyCode::Enter => { + // Выполнить выбранное действие + if let Some(profile) = &app.profile_info { + let actions = get_available_actions_count(profile); + let action_index = app.selected_profile_action; + + if action_index < actions { + // Определяем какое действие выбрано + let mut current_idx = 0; + + // Действие: Открыть в браузере + if profile.username.is_some() { + if action_index == current_idx { + if let Some(username) = &profile.username { + let url = format!("https://t.me/{}", username.trim_start_matches('@')); + match open::that(&url) { + Ok(_) => { + app.status_message = Some(format!("Открыто: {}", url)); + } + Err(e) => { + app.error_message = Some(format!("Ошибка открытия браузера: {}", e)); + } + } + } + return; + } + current_idx += 1; + } + + // Действие: Скопировать ID + 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(); + } + } + } + } + _ => {} + } + return; + } + // Режим поиска по сообщениям if app.is_message_search_mode() { match key.code { @@ -468,6 +568,29 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } + // Ctrl+U для профиля + if key.code == KeyCode::Char('u') && has_ctrl { + if let Some(chat_id) = app.selected_chat_id { + app.status_message = Some("Загрузка профиля...".to_string()); + match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await { + Ok(Ok(profile)) => { + app.profile_info = Some(profile); + app.enter_profile_mode(); + app.status_message = None; + } + Ok(Err(e)) => { + app.error_message = Some(e); + app.status_message = None; + } + Err(_) => { + app.error_message = Some("Таймаут загрузки профиля".to_string()); + app.status_message = None; + } + } + } + return; + } + match key.code { KeyCode::Backspace => { // Удаляем символ слева от курсора @@ -616,3 +739,20 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } } + +/// Подсчёт количества доступных действий в профиле +fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { + let mut count = 0; + + if profile.username.is_some() { + count += 1; // Открыть в браузере + } + + count += 1; // Скопировать ID + + if profile.is_group { + count += 1; // Покинуть группу + } + + count +} diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index bd2c40a..e14012b 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -167,6 +167,22 @@ pub struct FolderInfo { pub name: String, } +/// Информация о профиле чата/пользователя +#[derive(Debug, Clone)] +pub struct ProfileInfo { + pub chat_id: i64, + pub title: String, + pub username: Option, + pub bio: Option, + pub phone_number: Option, + pub chat_type: String, // "Личный чат", "Группа", "Канал" + pub member_count: Option, + pub description: Option, + pub invite_link: Option, + pub is_group: bool, + pub online_status: Option, +} + /// Состояние сетевого соединения #[derive(Debug, Clone, PartialEq)] pub enum NetworkState { @@ -1213,6 +1229,137 @@ impl TdClient { } } + /// Получение полной информации о чате для профиля + pub async fn get_profile_info(&self, chat_id: i64) -> Result { + use tdlib_rs::enums::ChatType; + + // Получаем основную информацию о чате + let chat_result = functions::get_chat(chat_id, self.client_id).await; + let chat = match chat_result { + Ok(tdlib_rs::enums::Chat::Chat(c)) => c, + Err(e) => return Err(format!("Ошибка загрузки чата: {:?}", e)), + }; + + let mut profile = ProfileInfo { + chat_id, + title: chat.title.clone(), + username: None, + bio: None, + phone_number: None, + chat_type: String::new(), + member_count: None, + description: None, + invite_link: None, + is_group: false, + online_status: None, + }; + + match &chat.r#type { + ChatType::Private(private_chat) => { + profile.chat_type = "Личный чат".to_string(); + profile.is_group = false; + + // Получаем полную информацию о пользователе + let user_result = functions::get_user(private_chat.user_id, self.client_id).await; + if let Ok(tdlib_rs::enums::User::User(user)) = user_result { + // Username + if let Some(usernames) = user.usernames { + if let Some(username) = usernames.active_usernames.first() { + profile.username = Some(format!("@{}", username)); + } + } + + // Phone number + if !user.phone_number.is_empty() { + profile.phone_number = Some(format!("+{}", user.phone_number)); + } + + // Online status + profile.online_status = Some(match user.status { + tdlib_rs::enums::UserStatus::Online(_) => "Онлайн".to_string(), + tdlib_rs::enums::UserStatus::Recently(_) => "Был(а) недавно".to_string(), + tdlib_rs::enums::UserStatus::LastWeek(_) => "Был(а) на этой неделе".to_string(), + tdlib_rs::enums::UserStatus::LastMonth(_) => "Был(а) в этом месяце".to_string(), + tdlib_rs::enums::UserStatus::Offline(offline) => { + crate::utils::format_was_online(offline.was_online) + } + _ => "Давно не был(а)".to_string(), + }); + } + + // Bio (getUserFullInfo) + let full_info_result = functions::get_user_full_info(private_chat.user_id, self.client_id).await; + if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = full_info_result { + if let Some(bio_obj) = full_info.bio { + profile.bio = Some(bio_obj.text); + } + } + } + ChatType::BasicGroup(basic_group) => { + profile.chat_type = "Группа".to_string(); + profile.is_group = true; + + // Получаем информацию о группе + let group_result = functions::get_basic_group(basic_group.basic_group_id, self.client_id).await; + if let Ok(tdlib_rs::enums::BasicGroup::BasicGroup(group)) = group_result { + profile.member_count = Some(group.member_count); + } + + // Полная информация о группе + let full_info_result = functions::get_basic_group_full_info(basic_group.basic_group_id, self.client_id).await; + if let Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) = full_info_result { + if !full_info.description.is_empty() { + profile.description = Some(full_info.description); + } + if let Some(link) = full_info.invite_link { + profile.invite_link = Some(link.invite_link); + } + } + } + ChatType::Supergroup(supergroup) => { + // Получаем информацию о супергруппе + let sg_result = functions::get_supergroup(supergroup.supergroup_id, self.client_id).await; + if let Ok(tdlib_rs::enums::Supergroup::Supergroup(sg)) = sg_result { + profile.chat_type = if sg.is_channel { "Канал".to_string() } else { "Супергруппа".to_string() }; + profile.is_group = !sg.is_channel; + profile.member_count = Some(sg.member_count); + + // Username + if let Some(usernames) = sg.usernames { + if let Some(username) = usernames.active_usernames.first() { + profile.username = Some(format!("@{}", username)); + } + } + } + + // Полная информация о супергруппе + let full_info_result = functions::get_supergroup_full_info(supergroup.supergroup_id, self.client_id).await; + if let Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) = full_info_result { + if !full_info.description.is_empty() { + profile.description = Some(full_info.description); + } + if let Some(link) = full_info.invite_link { + profile.invite_link = Some(link.invite_link); + } + } + } + ChatType::Secret(_) => { + profile.chat_type = "Секретный чат".to_string(); + } + } + + Ok(profile) + } + + /// Выйти из группы/канала + pub async fn leave_chat(&self, chat_id: i64) -> Result<(), String> { + let result = functions::leave_chat(chat_id, self.client_id).await; + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка выхода из чата: {:?}", e)), + } + } + /// Загрузка старых сообщений (для скролла вверх) pub async fn load_older_messages( &mut self, diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 4bd9a4c..f58cd6a 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -3,4 +3,5 @@ pub mod client; pub use client::TdClient; pub use client::UserOnlineStatus; pub use client::NetworkState; +pub use client::ProfileInfo; pub use tdlib_rs::enums::ChatAction; diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index 4547214..999a9e8 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -157,28 +157,5 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { /// Форматирование времени "был(а) в ..." fn format_was_online(timestamp: i32) -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let diff = now - timestamp; - - if diff < 60 { - "был(а) только что".to_string() - } else if diff < 3600 { - let mins = diff / 60; - format!("был(а) {} мин. назад", mins) - } else if diff < 86400 { - let hours = diff / 3600; - format!("был(а) {} ч. назад", hours) - } else { - // Показываем дату - let datetime = chrono::DateTime::from_timestamp(timestamp as i64, 0) - .map(|dt| dt.format("%d.%m %H:%M").to_string()) - .unwrap_or_else(|| "давно".to_string()); - format!("был(а) {}", datetime) - } + crate::utils::format_was_online(timestamp) } diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 7856119..95a5a6a 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -24,7 +24,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } else if app.is_searching { format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) } else if app.selected_chat_id.is_some() { - format!(" {}↑/↓: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) + format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) } else { format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) }; diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 0516a6a..9119d2f 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -310,6 +310,14 @@ fn adjust_entities_for_substring( } pub fn render(f: &mut Frame, area: Rect, app: &App) { + // Режим профиля + if app.is_profile_mode() { + if let Some(profile) = &app.profile_info { + crate::ui::profile::render(f, area, app, profile); + } + return; + } + // Режим поиска по сообщениям if app.is_message_search_mode() { render_search_mode(f, area, app); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ba751c1..9fd3679 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,7 @@ mod main_screen; mod chat_list; mod messages; mod footer; +pub mod profile; use ratatui::Frame; use ratatui::layout::Alignment; diff --git a/src/ui/profile.rs b/src/ui/profile.rs new file mode 100644 index 0000000..e4af9b3 --- /dev/null +++ b/src/ui/profile.rs @@ -0,0 +1,259 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use crate::app::App; +use crate::tdlib::client::ProfileInfo; + +/// Рендерит режим просмотра профиля +pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) { + // Проверяем, показывать ли модалку подтверждения + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + render_leave_confirmation_modal(f, area, confirmation_step); + return; + } + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Profile info + Constraint::Length(3), // Actions help + ]) + .split(area); + + // Header + let header_text = format!("👤 ПРОФИЛЬ: {}", profile.title); + let header = Paragraph::new(header_text) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + ) + .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Profile info + let mut lines: Vec = Vec::new(); + + // Тип чата + lines.push(Line::from(vec![ + Span::styled("Тип: ", Style::default().fg(Color::Gray)), + Span::styled(&profile.chat_type, Style::default().fg(Color::White)), + ])); + lines.push(Line::from("")); + + // ID + lines.push(Line::from(vec![ + Span::styled("ID: ", Style::default().fg(Color::Gray)), + Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)), + ])); + lines.push(Line::from("")); + + // Username + if let Some(username) = &profile.username { + lines.push(Line::from(vec![ + Span::styled("Username: ", Style::default().fg(Color::Gray)), + Span::styled(username, Style::default().fg(Color::Cyan)), + ])); + lines.push(Line::from("")); + } + + // Phone number (только для личных чатов) + if let Some(phone) = &profile.phone_number { + lines.push(Line::from(vec![ + Span::styled("Телефон: ", Style::default().fg(Color::Gray)), + Span::styled(phone, Style::default().fg(Color::White)), + ])); + lines.push(Line::from("")); + } + + // Online status (только для личных чатов) + if let Some(status) = &profile.online_status { + lines.push(Line::from(vec![ + Span::styled("Статус: ", Style::default().fg(Color::Gray)), + Span::styled(status, Style::default().fg(Color::Green)), + ])); + lines.push(Line::from("")); + } + + // Bio (только для личных чатов) + if let Some(bio) = &profile.bio { + lines.push(Line::from(vec![ + Span::styled("О себе: ", Style::default().fg(Color::Gray)), + ])); + // Разбиваем bio на строки если длинное + let bio_lines: Vec<&str> = bio.lines().collect(); + for bio_line in bio_lines { + lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White)))); + } + lines.push(Line::from("")); + } + + // Member count (для групп/каналов) + if let Some(count) = profile.member_count { + lines.push(Line::from(vec![ + Span::styled("Участников: ", Style::default().fg(Color::Gray)), + Span::styled(format!("{}", count), Style::default().fg(Color::White)), + ])); + lines.push(Line::from("")); + } + + // Description (для групп/каналов) + if let Some(desc) = &profile.description { + lines.push(Line::from(vec![ + Span::styled("Описание: ", Style::default().fg(Color::Gray)), + ])); + let desc_lines: Vec<&str> = desc.lines().collect(); + for desc_line in desc_lines { + lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White)))); + } + lines.push(Line::from("")); + } + + // Invite link (для групп/каналов) + if let Some(link) = &profile.invite_link { + lines.push(Line::from(vec![ + Span::styled("Ссылка: ", Style::default().fg(Color::Gray)), + Span::styled(link, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)), + ])); + lines.push(Line::from("")); + } + + // Разделитель + lines.push(Line::from("────────────────────────────────")); + lines.push(Line::from("")); + + // Действия + lines.push(Line::from(Span::styled( + "Действия:", + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + let actions = get_available_actions(profile); + for (idx, action) in actions.iter().enumerate() { + let is_selected = idx == app.selected_profile_action; + let marker = if is_selected { "▶ " } else { " " }; + let style = if is_selected { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + lines.push(Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Yellow)), + Span::styled(*action, style), + ])); + } + + let info_widget = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + ) + .scroll((0, 0)); + f.render_widget(info_widget, chunks[1]); + + // Help bar + let help_line = Line::from(vec![ + Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("навигация"), + Span::raw(" "), + Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw("выбрать"), + Span::raw(" "), + Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw("выход"), + ]); + let help = Paragraph::new(help_line) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + ) + .alignment(Alignment::Center); + f.render_widget(help, chunks[2]); +} + +/// Получить список доступных действий +fn get_available_actions(profile: &ProfileInfo) -> Vec<&'static str> { + let mut actions = vec![]; + + if profile.username.is_some() { + actions.push("Открыть в браузере"); + } + + actions.push("Скопировать ID"); + + if profile.is_group { + actions.push("Покинуть группу"); + } + + actions +} + +/// Рендерит модалку подтверждения выхода из группы +fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) { + // Затемняем фон + let modal_area = centered_rect(60, 30, area); + + let text = if step == 1 { + "Вы хотите выйти из группы?" + } else { + "Вы ТОЧНО хотите выйти из группы?!?!?" + }; + + let lines = vec![ + Line::from(""), + Line::from(Span::styled( + text, + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(""), + Line::from(vec![ + Span::styled("y/н/Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(" — да "), + Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw(" — нет"), + ]), + ]; + + let modal = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)) + .title(" ⚠ ВНИМАНИЕ ") + .title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) + ) + .alignment(Alignment::Center); + + f.render_widget(modal, modal_area); +} + +/// Вспомогательная функция для центрирования прямоугольника +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/utils.rs b/src/utils.rs index 832aa94..dd10a00 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -108,3 +108,31 @@ pub fn format_datetime(timestamp: i32) -> String { format!("{:02}.{:02}.{} {:02}:{:02}", day + 1, month, year, local_hours, minutes) } + +/// Форматирование "был(а) онлайн" из timestamp +pub fn format_was_online(timestamp: i32) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i32; + + let diff = now - timestamp; + + if diff < 60 { + "был(а) только что".to_string() + } else if diff < 3600 { + let mins = diff / 60; + format!("был(а) {} мин. назад", mins) + } else if diff < 86400 { + let hours = diff / 3600; + format!("был(а) {} ч. назад", hours) + } else { + // Показываем дату + let datetime = chrono::DateTime::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.format("%d.%m %H:%M").to_string()) + .unwrap_or_else(|| "давно".to_string()); + format!("был(а) {}", datetime) + } +}