add account profile

This commit is contained in:
Mikhail Kilin
2026-01-27 13:41:29 +03:00
parent ac684da820
commit 356d2d3064
15 changed files with 787 additions and 26 deletions

View File

@@ -66,6 +66,15 @@ pub struct App {
pub message_search_results: Vec<crate::tdlib::client::MessageInfo>,
/// Индекс выбранного результата
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 {
@@ -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
}
}

View File

@@ -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
}

View File

@@ -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<String>,
pub bio: Option<String>,
pub phone_number: Option<String>,
pub chat_type: String, // "Личный чат", "Группа", "Канал"
pub member_count: Option<i32>,
pub description: Option<String>,
pub invite_link: Option<String>,
pub is_group: bool,
pub online_status: Option<String>,
}
/// Состояние сетевого соединения
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkState {
@@ -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(
&mut self,

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -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)
};

View File

@@ -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);

View File

@@ -4,6 +4,7 @@ mod main_screen;
mod chat_list;
mod messages;
mod footer;
pub mod profile;
use ratatui::Frame;
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)
}
/// Форматирование "был(а) онлайн" из 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)
}
}