yet-another-changes #10

Merged
killingdruid merged 4 commits from yet-another-changes into main 2026-01-27 22:43:13 +00:00
15 changed files with 787 additions and 26 deletions
Showing only changes of commit 356d2d3064 - Show all commits

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

89
.serena/project.yml Normal file
View File

@@ -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: []

37
Cargo.lock generated
View File

@@ -989,6 +989,25 @@ dependencies = [
"serde", "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]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.13.0"
@@ -1175,6 +1194,17 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 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]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.75" version = "0.10.75"
@@ -1254,6 +1284,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.12.2"
@@ -1878,6 +1914,7 @@ dependencies = [
"chrono", "chrono",
"crossterm", "crossterm",
"dotenvy", "dotenvy",
"open",
"ratatui", "ratatui",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -12,6 +12,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
dotenvy = "0.15" dotenvy = "0.15"
chrono = "0.4" chrono = "0.4"
open = "5.0"
[build-dependencies] [build-dependencies]
tdlib-rs = { version = "1.1", features = ["download-tdlib"] } tdlib-rs = { version = "1.1", features = ["download-tdlib"] }

View File

@@ -99,4 +99,7 @@ cargo run
5. Ждёт фидбек 5. Ждёт фидбек
6. Переходит к следующему этапу 6. Переходит к следующему этапу
``` ```
## Работа с git
- никогда не добавляй себя в соавторов в тексте коммита

View File

@@ -66,6 +66,15 @@ pub struct App {
pub message_search_results: Vec<crate::tdlib::client::MessageInfo>, pub message_search_results: Vec<crate::tdlib::client::MessageInfo>,
/// Индекс выбранного результата /// Индекс выбранного результата
pub selected_search_result_index: usize, 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<crate::tdlib::ProfileInfo>,
} }
impl App { impl App {
@@ -106,6 +115,10 @@ impl App {
message_search_query: String::new(), message_search_query: String::new(),
message_search_results: Vec::new(), message_search_results: Vec::new(),
selected_search_result_index: 0, 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(); 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
}
} }

View File

@@ -56,9 +56,109 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
return; 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() { if app.is_message_search_mode() {
match key.code { match key.code {
@@ -468,6 +568,29 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return; 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 { match key.code {
KeyCode::Backspace => { 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
}

View File

@@ -167,6 +167,22 @@ pub struct FolderInfo {
pub name: String, pub name: String,
} }
/// Информация о профиле чата/пользователя
#[derive(Debug, Clone)]
pub struct ProfileInfo {
pub chat_id: i64,
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)] #[derive(Debug, Clone, PartialEq)]
pub enum NetworkState { pub enum NetworkState {
@@ -1213,6 +1229,137 @@ impl TdClient {
} }
} }
/// Получение полной информации о чате для профиля
pub async fn get_profile_info(&self, chat_id: i64) -> Result<ProfileInfo, String> {
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( pub async fn load_older_messages(
&mut self, &mut self,

View File

@@ -3,4 +3,5 @@ pub mod client;
pub use client::TdClient; pub use client::TdClient;
pub use client::UserOnlineStatus; pub use client::UserOnlineStatus;
pub use client::NetworkState; pub use client::NetworkState;
pub use client::ProfileInfo;
pub use tdlib_rs::enums::ChatAction; pub use tdlib_rs::enums::ChatAction;

View File

@@ -157,28 +157,5 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
/// Форматирование времени "был(а) в ..." /// Форматирование времени "был(а) в ..."
fn format_was_online(timestamp: i32) -> String { fn format_was_online(timestamp: i32) -> String {
use std::time::{SystemTime, UNIX_EPOCH}; crate::utils::format_was_online(timestamp)
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)
}
} }

View File

@@ -24,7 +24,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
} else if app.is_searching { } else if app.is_searching {
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
} else if app.selected_chat_id.is_some() { } 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 { } else {
format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
}; };

View File

@@ -310,6 +310,14 @@ fn adjust_entities_for_substring(
} }
pub fn render(f: &mut Frame, area: Rect, app: &App) { 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() { if app.is_message_search_mode() {
render_search_mode(f, area, app); render_search_mode(f, area, app);

View File

@@ -4,6 +4,7 @@ mod main_screen;
mod chat_list; mod chat_list;
mod messages; mod messages;
mod footer; mod footer;
pub mod profile;
use ratatui::Frame; use ratatui::Frame;
use ratatui::layout::Alignment; use ratatui::layout::Alignment;

259
src/ui/profile.rs Normal file
View File

@@ -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<Line> = Vec::new();
// Тип чата
lines.push(Line::from(vec![
Span::styled("Тип: ", Style::default().fg(Color::Gray)),
Span::styled(&profile.chat_type, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// ID
lines.push(Line::from(vec![
Span::styled("ID: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", profile.chat_id), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
// Username
if let Some(username) = &profile.username {
lines.push(Line::from(vec![
Span::styled("Username: ", Style::default().fg(Color::Gray)),
Span::styled(username, Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(""));
}
// Phone number (только для личных чатов)
if let Some(phone) = &profile.phone_number {
lines.push(Line::from(vec![
Span::styled("Телефон: ", Style::default().fg(Color::Gray)),
Span::styled(phone, Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Online status (только для личных чатов)
if let Some(status) = &profile.online_status {
lines.push(Line::from(vec![
Span::styled("Статус: ", Style::default().fg(Color::Gray)),
Span::styled(status, Style::default().fg(Color::Green)),
]));
lines.push(Line::from(""));
}
// Bio (только для личных чатов)
if let Some(bio) = &profile.bio {
lines.push(Line::from(vec![
Span::styled("О себе: ", Style::default().fg(Color::Gray)),
]));
// Разбиваем bio на строки если длинное
let bio_lines: Vec<&str> = bio.lines().collect();
for bio_line in bio_lines {
lines.push(Line::from(Span::styled(bio_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Member count (для групп/каналов)
if let Some(count) = profile.member_count {
lines.push(Line::from(vec![
Span::styled("Участников: ", Style::default().fg(Color::Gray)),
Span::styled(format!("{}", count), Style::default().fg(Color::White)),
]));
lines.push(Line::from(""));
}
// Description (для групп/каналов)
if let Some(desc) = &profile.description {
lines.push(Line::from(vec![
Span::styled("Описание: ", Style::default().fg(Color::Gray)),
]));
let desc_lines: Vec<&str> = desc.lines().collect();
for desc_line in desc_lines {
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
}
lines.push(Line::from(""));
}
// Invite link (для групп/каналов)
if let Some(link) = &profile.invite_link {
lines.push(Line::from(vec![
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
Span::styled(link, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)),
]));
lines.push(Line::from(""));
}
// Разделитель
lines.push(Line::from("────────────────────────────────"));
lines.push(Line::from(""));
// Действия
lines.push(Line::from(Span::styled(
"Действия:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let actions = get_available_actions(profile);
for (idx, action) in actions.iter().enumerate() {
let is_selected = idx == app.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]
}

View File

@@ -108,3 +108,31 @@ pub fn format_datetime(timestamp: i32) -> String {
format!("{:02}.{:02}.{} {:02}:{:02}", day + 1, month, year, local_hours, minutes) 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)
}
}