This commit is contained in:
Mikhail Kilin
2026-01-30 17:26:21 +03:00
parent a4cf6bac72
commit 433233d766
11 changed files with 603 additions and 315 deletions

162
src/app/chat_state.rs Normal file
View File

@@ -0,0 +1,162 @@
// Chat state management - type-safe state machine for chat modes
use crate::tdlib::client::MessageInfo;
use crate::tdlib::ProfileInfo;
/// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone)]
pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста
Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
MessageSelection {
/// Индекс выбранного сообщения (снизу вверх, 0 = последнее)
selected_index: usize,
},
/// Редактирование сообщения
Editing {
/// ID редактируемого сообщения
message_id: i64,
/// Индекс сообщения в списке
selected_index: usize,
},
/// Ответ на сообщение (reply)
Reply {
/// ID сообщения, на которое отвечаем
message_id: i64,
},
/// Пересылка сообщения (forward)
Forward {
/// ID сообщения для пересылки
message_id: i64,
/// Находимся в режиме выбора чата для пересылки
selecting_chat: bool,
},
/// Подтверждение удаления сообщения
DeleteConfirmation {
/// ID сообщения для удаления
message_id: i64,
},
/// Выбор реакции на сообщение
ReactionPicker {
/// ID сообщения для реакции
message_id: i64,
/// Список доступных реакций
available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
selected_index: usize,
},
/// Просмотр профиля пользователя/чата
Profile {
/// Информация профиля
info: ProfileInfo,
/// Индекс выбранного действия
selected_action: usize,
/// Шаг подтверждения выхода из группы (0 = не показано, 1-2 = подтверждения)
leave_group_confirmation_step: u8,
},
/// Поиск по сообщениям в текущем чате
SearchInChat {
/// Поисковый запрос
query: String,
/// Результаты поиска
results: Vec<MessageInfo>,
/// Индекс выбранного результата
selected_index: usize,
},
/// Просмотр закреплённых сообщений
PinnedMessages {
/// Список закреплённых сообщений
messages: Vec<MessageInfo>,
/// Индекс выбранного pinned сообщения
selected_index: usize,
},
}
impl Default for ChatState {
fn default() -> Self {
ChatState::Normal
}
}
impl ChatState {
/// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool {
matches!(self, ChatState::MessageSelection { .. })
}
/// Проверка: находимся в режиме редактирования
pub fn is_editing(&self) -> bool {
matches!(self, ChatState::Editing { .. })
}
/// Проверка: находимся в режиме ответа
pub fn is_reply(&self) -> bool {
matches!(self, ChatState::Reply { .. })
}
/// Проверка: находимся в режиме пересылки
pub fn is_forward(&self) -> bool {
matches!(self, ChatState::Forward { .. })
}
/// Проверка: показываем подтверждение удаления
pub fn is_delete_confirmation(&self) -> bool {
matches!(self, ChatState::DeleteConfirmation { .. })
}
/// Проверка: показываем reaction picker
pub fn is_reaction_picker(&self) -> bool {
matches!(self, ChatState::ReactionPicker { .. })
}
/// Проверка: показываем профиль
pub fn is_profile(&self) -> bool {
matches!(self, ChatState::Profile { .. })
}
/// Проверка: находимся в режиме поиска по сообщениям
pub fn is_search_in_chat(&self) -> bool {
matches!(self, ChatState::SearchInChat { .. })
}
/// Проверка: показываем pinned сообщения
pub fn is_pinned_mode(&self) -> bool {
matches!(self, ChatState::PinnedMessages { .. })
}
/// Проверка: находимся в обычном режиме
pub fn is_normal(&self) -> bool {
matches!(self, ChatState::Normal)
}
/// Возвращает ID выбранного сообщения (если есть)
pub fn selected_message_id(&self) -> Option<i64> {
match self {
ChatState::Editing { message_id, .. } => Some(*message_id),
ChatState::Reply { message_id } => Some(*message_id),
ChatState::Forward { message_id, .. } => Some(*message_id),
ChatState::DeleteConfirmation { message_id } => Some(*message_id),
ChatState::ReactionPicker { message_id, .. } => Some(*message_id),
_ => None,
}
}
/// Возвращает индекс выбранного сообщения (если есть)
pub fn selected_message_index(&self) -> Option<usize> {
match self {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
ChatState::Editing { selected_index, .. } => Some(*selected_index),
_ => None,
}
}
}

View File

@@ -1,5 +1,7 @@
mod chat_state;
mod state; mod state;
pub use chat_state::ChatState;
pub use state::AppScreen; pub use state::AppScreen;
use crate::tdlib::client::ChatInfo; use crate::tdlib::client::ChatInfo;
@@ -10,6 +12,8 @@ pub struct App {
pub config: crate::config::Config, pub config: crate::config::Config,
pub screen: AppScreen, pub screen: AppScreen,
pub td_client: TdClient, pub td_client: TdClient,
/// Состояние чата - type-safe state machine (новое!)
pub chat_state: ChatState,
// Auth state // Auth state
pub phone_input: String, pub phone_input: String,
pub code_input: String, pub code_input: String,
@@ -32,59 +36,9 @@ pub struct App {
pub search_query: String, pub search_query: String,
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях /// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
pub needs_redraw: bool, pub needs_redraw: bool,
// Edit message state
/// ID сообщения, которое редактируется (None = режим отправки нового)
pub editing_message_id: Option<i64>,
/// Индекс выбранного сообщения для навигации (снизу вверх, 0 = последнее)
pub selected_message_index: Option<usize>,
// Delete confirmation
/// ID сообщения для подтверждения удаления (показывает модалку)
pub confirm_delete_message_id: Option<i64>,
// Reply state
/// ID сообщения, на которое отвечаем (None = обычная отправка)
pub replying_to_message_id: Option<i64>,
// Forward state
/// ID сообщения для пересылки
pub forwarding_message_id: Option<i64>,
/// Режим выбора чата для пересылки
pub is_selecting_forward_chat: bool,
// Typing indicator // Typing indicator
/// Время последней отправки typing status (для throttling) /// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>, pub last_typing_sent: Option<std::time::Instant>,
// Pinned messages mode
/// Режим просмотра закреплённых сообщений
pub is_pinned_mode: bool,
/// Список закреплённых сообщений
pub pinned_messages: Vec<crate::tdlib::client::MessageInfo>,
/// Индекс выбранного pinned сообщения
pub selected_pinned_index: usize,
// Message search mode
/// Режим поиска по сообщениям
pub is_message_search_mode: bool,
/// Поисковый запрос
pub message_search_query: String,
/// Результаты поиска
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>,
// Reaction picker mode
/// Режим выбора реакции
pub is_reaction_picker_mode: bool,
/// ID сообщения для добавления реакции
pub selected_message_for_reaction: Option<i64>,
/// Список доступных реакций
pub available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
pub selected_reaction_index: usize,
} }
impl App { impl App {
@@ -96,6 +50,7 @@ impl App {
config, config,
screen: AppScreen::Loading, screen: AppScreen::Loading,
td_client: TdClient::new(), td_client: TdClient::new(),
chat_state: ChatState::Normal,
phone_input: String::new(), phone_input: String::new(),
code_input: String::new(), code_input: String::new(),
password_input: String::new(), password_input: String::new(),
@@ -112,28 +67,7 @@ impl App {
is_searching: false, is_searching: false,
search_query: String::new(), search_query: String::new(),
needs_redraw: true, needs_redraw: true,
editing_message_id: None,
selected_message_index: None,
confirm_delete_message_id: None,
replying_to_message_id: None,
forwarding_message_id: None,
is_selecting_forward_chat: false,
last_typing_sent: None, last_typing_sent: None,
is_pinned_mode: false,
pinned_messages: Vec::new(),
selected_pinned_index: 0,
is_message_search_mode: false,
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,
is_reaction_picker_mode: false,
selected_message_for_reaction: None,
available_reactions: Vec::new(),
selected_reaction_index: 0,
} }
} }
@@ -187,24 +121,14 @@ impl App {
self.message_input.clear(); self.message_input.clear();
self.cursor_position = 0; self.cursor_position = 0;
self.message_scroll_offset = 0; self.message_scroll_offset = 0;
self.editing_message_id = None;
self.selected_message_index = None;
self.replying_to_message_id = None;
self.last_typing_sent = None; self.last_typing_sent = None;
// Сбрасываем pinned режим // Сбрасываем состояние чата в нормальный режим
self.is_pinned_mode = false; self.chat_state = ChatState::Normal;
self.pinned_messages.clear();
self.selected_pinned_index = 0;
// Очищаем данные в TdClient // Очищаем данные в TdClient
self.td_client.current_chat_id = None; self.td_client.current_chat_id = None;
self.td_client.current_chat_messages.clear(); self.td_client.current_chat_messages.clear();
self.td_client.typing_status = None; self.td_client.typing_status = None;
self.td_client.current_pinned_message = None; self.td_client.current_pinned_message = None;
// Сбрасываем режим поиска
self.is_message_search_mode = false;
self.message_search_query.clear();
self.message_search_results.clear();
self.selected_search_result_index = 0;
} }
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте) /// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
@@ -213,7 +137,7 @@ impl App {
return; return;
} }
// Начинаем с последнего сообщения (индекс 0 = самое новое снизу) // Начинаем с последнего сообщения (индекс 0 = самое новое снизу)
self.selected_message_index = Some(0); self.chat_state = ChatState::MessageSelection { selected_index: 0 };
} }
/// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс) /// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс)
@@ -222,24 +146,25 @@ impl App {
if total == 0 { if total == 0 {
return; return;
} }
self.selected_message_index = Some( if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
self.selected_message_index *selected_index = (*selected_index + 1).min(total - 1);
.map(|i| (i + 1).min(total - 1)) }
.unwrap_or(0),
);
} }
/// Выбрать следующее сообщение (вниз по списку = уменьшить индекс) /// Выбрать следующее сообщение (вниз по списку = уменьшить индекс)
pub fn select_next_message(&mut self) { pub fn select_next_message(&mut self) {
self.selected_message_index = self if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
.selected_message_index if *selected_index > 0 {
.map(|i| if i > 0 { Some(i - 1) } else { None }) *selected_index -= 1;
.flatten(); } else {
self.chat_state = ChatState::Normal;
}
}
} }
/// Получить выбранное сообщение /// Получить выбранное сообщение
pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> { pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.selected_message_index.and_then(|idx| { self.chat_state.selected_message_index().and_then(|idx| {
let total = self.td_client.current_chat_messages.len(); let total = self.td_client.current_chat_messages.len();
if total == 0 || idx >= total { if total == 0 || idx >= total {
return None; return None;
@@ -251,21 +176,33 @@ impl App {
/// Начать редактирование выбранного сообщения /// Начать редактирование выбранного сообщения
pub fn start_editing_selected(&mut self) -> bool { pub fn start_editing_selected(&mut self) -> bool {
// Получаем selected_index из текущего состояния
let selected_idx = match &self.chat_state {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
_ => None,
};
if selected_idx.is_none() {
return false;
}
// Сначала извлекаем данные из сообщения // Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| { let msg_data = self.get_selected_message().and_then(|msg| {
if msg.can_be_edited && msg.is_outgoing { if msg.can_be_edited && msg.is_outgoing {
Some((msg.id, msg.content.clone())) Some((msg.id, msg.content.clone(), selected_idx.unwrap()))
} else { } else {
None None
} }
}); });
// Затем присваиваем // Затем присваиваем
if let Some((id, content)) = msg_data { if let Some((id, content, idx)) = msg_data {
self.editing_message_id = Some(id);
self.cursor_position = content.chars().count(); self.cursor_position = content.chars().count();
self.message_input = content; self.message_input = content;
self.selected_message_index = None; self.chat_state = ChatState::Editing {
message_id: id,
selected_index: idx,
};
return true; return true;
} }
false false
@@ -273,20 +210,19 @@ impl App {
/// Отменить редактирование /// Отменить редактирование
pub fn cancel_editing(&mut self) { pub fn cancel_editing(&mut self) {
self.editing_message_id = None; self.chat_state = ChatState::Normal;
self.selected_message_index = None;
self.message_input.clear(); self.message_input.clear();
self.cursor_position = 0; self.cursor_position = 0;
} }
/// Проверить, находимся ли в режиме редактирования /// Проверить, находимся ли в режиме редактирования
pub fn is_editing(&self) -> bool { pub fn is_editing(&self) -> bool {
self.editing_message_id.is_some() self.chat_state.is_editing()
} }
/// Проверить, находимся ли в режиме выбора сообщения /// Проверить, находимся ли в режиме выбора сообщения
pub fn is_selecting_message(&self) -> bool { pub fn is_selecting_message(&self) -> bool {
self.selected_message_index.is_some() self.chat_state.is_message_selection()
} }
pub fn get_selected_chat_id(&self) -> Option<i64> { pub fn get_selected_chat_id(&self) -> Option<i64> {
@@ -385,14 +321,15 @@ impl App {
/// Проверить, показывается ли модалка подтверждения удаления /// Проверить, показывается ли модалка подтверждения удаления
pub fn is_confirm_delete_shown(&self) -> bool { pub fn is_confirm_delete_shown(&self) -> bool {
self.confirm_delete_message_id.is_some() self.chat_state.is_delete_confirmation()
} }
/// Начать режим ответа на выбранное сообщение /// Начать режим ответа на выбранное сообщение
pub fn start_reply_to_selected(&mut self) -> bool { pub fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() { if let Some(msg) = self.get_selected_message() {
self.replying_to_message_id = Some(msg.id); self.chat_state = ChatState::Reply {
self.selected_message_index = None; message_id: msg.id,
};
return true; return true;
} }
false false
@@ -400,17 +337,17 @@ impl App {
/// Отменить режим ответа /// Отменить режим ответа
pub fn cancel_reply(&mut self) { pub fn cancel_reply(&mut self) {
self.replying_to_message_id = None; self.chat_state = ChatState::Normal;
} }
/// Проверить, находимся ли в режиме ответа /// Проверить, находимся ли в режиме ответа
pub fn is_replying(&self) -> bool { pub fn is_replying(&self) -> bool {
self.replying_to_message_id.is_some() self.chat_state.is_reply()
} }
/// Получить сообщение, на которое отвечаем /// Получить сообщение, на которое отвечаем
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> { pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.replying_to_message_id.and_then(|id| { self.chat_state.selected_message_id().and_then(|id| {
self.td_client self.td_client
.current_chat_messages .current_chat_messages
.iter() .iter()
@@ -421,9 +358,10 @@ impl App {
/// Начать режим пересылки выбранного сообщения /// Начать режим пересылки выбранного сообщения
pub fn start_forward_selected(&mut self) -> bool { pub fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() { if let Some(msg) = self.get_selected_message() {
self.forwarding_message_id = Some(msg.id); self.chat_state = ChatState::Forward {
self.selected_message_index = None; message_id: msg.id,
self.is_selecting_forward_chat = true; selecting_chat: true,
};
// Сбрасываем выбор чата на первый // Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0)); self.chat_list_state.select(Some(0));
return true; return true;
@@ -433,18 +371,20 @@ impl App {
/// Отменить режим пересылки /// Отменить режим пересылки
pub fn cancel_forward(&mut self) { pub fn cancel_forward(&mut self) {
self.forwarding_message_id = None; self.chat_state = ChatState::Normal;
self.is_selecting_forward_chat = false;
} }
/// Проверить, находимся ли в режиме выбора чата для пересылки /// Проверить, находимся ли в режиме выбора чата для пересылки
pub fn is_forwarding(&self) -> bool { pub fn is_forwarding(&self) -> bool {
self.is_selecting_forward_chat && self.forwarding_message_id.is_some() self.chat_state.is_forward()
} }
/// Получить сообщение для пересылки /// Получить сообщение для пересылки
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> { pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.forwarding_message_id.and_then(|id| { if !self.chat_state.is_forward() {
return None;
}
self.chat_state.selected_message_id().and_then(|id| {
self.td_client self.td_client
.current_chat_messages .current_chat_messages
.iter() .iter()
@@ -456,44 +396,57 @@ impl App {
/// Проверка режима pinned /// Проверка режима pinned
pub fn is_pinned_mode(&self) -> bool { pub fn is_pinned_mode(&self) -> bool {
self.is_pinned_mode self.chat_state.is_pinned_mode()
} }
/// Войти в режим pinned (вызывается после загрузки pinned сообщений) /// Войти в режим pinned (вызывается после загрузки pinned сообщений)
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::client::MessageInfo>) { pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::client::MessageInfo>) {
if !messages.is_empty() { if !messages.is_empty() {
self.pinned_messages = messages; self.chat_state = ChatState::PinnedMessages {
self.selected_pinned_index = 0; messages,
self.is_pinned_mode = true; selected_index: 0,
};
} }
} }
/// Выйти из режима pinned /// Выйти из режима pinned
pub fn exit_pinned_mode(&mut self) { pub fn exit_pinned_mode(&mut self) {
self.is_pinned_mode = false; self.chat_state = ChatState::Normal;
self.pinned_messages.clear();
self.selected_pinned_index = 0;
} }
/// Выбрать предыдущий pinned (вверх = более старый) /// Выбрать предыдущий pinned (вверх = более старый)
pub fn select_previous_pinned(&mut self) { pub fn select_previous_pinned(&mut self) {
if !self.pinned_messages.is_empty() if let ChatState::PinnedMessages {
&& self.selected_pinned_index < self.pinned_messages.len() - 1 selected_index,
messages,
} = &mut self.chat_state
{ {
self.selected_pinned_index += 1; if *selected_index + 1 < messages.len() {
*selected_index += 1;
}
} }
} }
/// Выбрать следующий pinned (вниз = более новый) /// Выбрать следующий pinned (вниз = более новый)
pub fn select_next_pinned(&mut self) { pub fn select_next_pinned(&mut self) {
if self.selected_pinned_index > 0 { if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
self.selected_pinned_index -= 1; if *selected_index > 0 {
*selected_index -= 1;
}
} }
} }
/// Получить текущее выбранное pinned сообщение /// Получить текущее выбранное pinned сообщение
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> { pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.pinned_messages.get(self.selected_pinned_index) if let ChatState::PinnedMessages {
messages,
selected_index,
} = &self.chat_state
{
messages.get(*selected_index)
} else {
None
}
} }
/// Получить ID текущего pinned для перехода в историю /// Получить ID текущего pinned для перехода в историю
@@ -505,51 +458,66 @@ impl App {
/// Проверить, активен ли режим поиска по сообщениям /// Проверить, активен ли режим поиска по сообщениям
pub fn is_message_search_mode(&self) -> bool { pub fn is_message_search_mode(&self) -> bool {
self.is_message_search_mode self.chat_state.is_search_in_chat()
} }
/// Войти в режим поиска по сообщениям /// Войти в режим поиска по сообщениям
pub fn enter_message_search_mode(&mut self) { pub fn enter_message_search_mode(&mut self) {
self.is_message_search_mode = true; self.chat_state = ChatState::SearchInChat {
self.message_search_query.clear(); query: String::new(),
self.message_search_results.clear(); results: Vec::new(),
self.selected_search_result_index = 0; selected_index: 0,
};
} }
/// Выйти из режима поиска /// Выйти из режима поиска
pub fn exit_message_search_mode(&mut self) { pub fn exit_message_search_mode(&mut self) {
self.is_message_search_mode = false; self.chat_state = ChatState::Normal;
self.message_search_query.clear();
self.message_search_results.clear();
self.selected_search_result_index = 0;
} }
/// Установить результаты поиска /// Установить результаты поиска
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::client::MessageInfo>) { pub fn set_search_results(&mut self, results: Vec<crate::tdlib::client::MessageInfo>) {
self.message_search_results = results; if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
self.selected_search_result_index = 0; *r = results;
*selected_index = 0;
}
} }
/// Выбрать предыдущий результат (вверх) /// Выбрать предыдущий результат (вверх)
pub fn select_previous_search_result(&mut self) { pub fn select_previous_search_result(&mut self) {
if self.selected_search_result_index > 0 { if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
self.selected_search_result_index -= 1; if *selected_index > 0 {
*selected_index -= 1;
}
} }
} }
/// Выбрать следующий результат (вниз) /// Выбрать следующий результат (вниз)
pub fn select_next_search_result(&mut self) { pub fn select_next_search_result(&mut self) {
if !self.message_search_results.is_empty() if let ChatState::SearchInChat {
&& self.selected_search_result_index < self.message_search_results.len() - 1 selected_index,
results,
..
} = &mut self.chat_state
{ {
self.selected_search_result_index += 1; if *selected_index + 1 < results.len() {
*selected_index += 1;
}
} }
} }
/// Получить текущий выбранный результат /// Получить текущий выбранный результат
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> { pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.message_search_results if let ChatState::SearchInChat {
.get(self.selected_search_result_index) results,
selected_index,
..
} = &self.chat_state
{
results.get(*selected_index)
} else {
None
}
} }
/// Получить ID выбранного результата для перехода /// Получить ID выбранного результата для перехода
@@ -557,6 +525,40 @@ impl App {
self.get_selected_search_result().map(|m| m.id) self.get_selected_search_result().map(|m| m.id)
} }
/// Получить поисковый запрос из режима поиска
pub fn get_search_query(&self) -> Option<&str> {
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
Some(query.as_str())
} else {
None
}
}
/// Обновить поисковый запрос
pub fn update_search_query(&mut self, new_query: String) {
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
*query = new_query;
}
}
/// Получить индекс выбранного результата поиска
pub fn get_search_selected_index(&self) -> Option<usize> {
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
Some(*selected_index)
} else {
None
}
}
/// Получить результаты поиска
pub fn get_search_results(&self) -> Option<&[crate::tdlib::client::MessageInfo]> {
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
Some(results.as_slice())
} else {
None
}
}
// === Draft Management === // === Draft Management ===
/// Получить черновик для текущего чата /// Получить черновик для текущего чата
@@ -581,62 +583,118 @@ impl App {
/// Проверить, активен ли режим профиля /// Проверить, активен ли режим профиля
pub fn is_profile_mode(&self) -> bool { pub fn is_profile_mode(&self) -> bool {
self.is_profile_mode self.chat_state.is_profile()
} }
/// Войти в режим профиля /// Войти в режим профиля
pub fn enter_profile_mode(&mut self) { pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) {
self.is_profile_mode = true; self.chat_state = ChatState::Profile {
self.selected_profile_action = 0; info,
self.leave_group_confirmation_step = 0; selected_action: 0,
leave_group_confirmation_step: 0,
};
} }
/// Выйти из режима профиля /// Выйти из режима профиля
pub fn exit_profile_mode(&mut self) { pub fn exit_profile_mode(&mut self) {
self.is_profile_mode = false; self.chat_state = ChatState::Normal;
self.selected_profile_action = 0;
self.leave_group_confirmation_step = 0;
self.profile_info = None;
} }
/// Выбрать предыдущее действие /// Выбрать предыдущее действие
pub fn select_previous_profile_action(&mut self) { pub fn select_previous_profile_action(&mut self) {
if self.selected_profile_action > 0 { if let ChatState::Profile {
self.selected_profile_action -= 1; selected_action, ..
} = &mut self.chat_state
{
if *selected_action > 0 {
*selected_action -= 1;
}
} }
} }
/// Выбрать следующее действие /// Выбрать следующее действие
pub fn select_next_profile_action(&mut self, max_actions: usize) { pub fn select_next_profile_action(&mut self, max_actions: usize) {
if self.selected_profile_action < max_actions.saturating_sub(1) { if let ChatState::Profile {
self.selected_profile_action += 1; selected_action, ..
} = &mut self.chat_state
{
if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1;
}
} }
} }
/// Показать первое подтверждение выхода из группы /// Показать первое подтверждение выхода из группы
pub fn show_leave_group_confirmation(&mut self) { pub fn show_leave_group_confirmation(&mut self) {
self.leave_group_confirmation_step = 1; if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 1;
}
} }
/// Показать второе подтверждение выхода из группы /// Показать второе подтверждение выхода из группы
pub fn show_leave_group_final_confirmation(&mut self) { pub fn show_leave_group_final_confirmation(&mut self) {
self.leave_group_confirmation_step = 2; if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 2;
}
} }
/// Отменить подтверждение выхода из группы /// Отменить подтверждение выхода из группы
pub fn cancel_leave_group(&mut self) { pub fn cancel_leave_group(&mut self) {
self.leave_group_confirmation_step = 0; if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 0;
}
} }
/// Получить текущий шаг подтверждения /// Получить текущий шаг подтверждения
pub fn get_leave_group_confirmation_step(&self) -> u8 { pub fn get_leave_group_confirmation_step(&self) -> u8 {
self.leave_group_confirmation_step if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &self.chat_state
{
*leave_group_confirmation_step
} else {
0
}
}
/// Получить информацию профиля
pub fn get_profile_info(&self) -> Option<&crate::tdlib::ProfileInfo> {
if let ChatState::Profile { info, .. } = &self.chat_state {
Some(info)
} else {
None
}
}
/// Получить индекс выбранного действия в профиле
pub fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile {
selected_action, ..
} = &self.chat_state
{
Some(*selected_action)
} else {
None
}
} }
// ========== Reaction Picker ========== // ========== Reaction Picker ==========
pub fn is_reaction_picker_mode(&self) -> bool { pub fn is_reaction_picker_mode(&self) -> bool {
self.is_reaction_picker_mode self.chat_state.is_reaction_picker()
} }
pub fn enter_reaction_picker_mode( pub fn enter_reaction_picker_mode(
@@ -644,36 +702,52 @@ impl App {
message_id: i64, message_id: i64,
available_reactions: Vec<String>, available_reactions: Vec<String>,
) { ) {
self.is_reaction_picker_mode = true; self.chat_state = ChatState::ReactionPicker {
self.selected_message_for_reaction = Some(message_id); message_id,
self.available_reactions = available_reactions; available_reactions,
self.selected_reaction_index = 0; selected_index: 0,
};
} }
pub fn exit_reaction_picker_mode(&mut self) { pub fn exit_reaction_picker_mode(&mut self) {
self.is_reaction_picker_mode = false; self.chat_state = ChatState::Normal;
self.selected_message_for_reaction = None;
self.available_reactions.clear();
self.selected_reaction_index = 0;
} }
pub fn select_previous_reaction(&mut self) { pub fn select_previous_reaction(&mut self) {
if !self.available_reactions.is_empty() && self.selected_reaction_index > 0 { if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
self.selected_reaction_index -= 1; if *selected_index > 0 {
*selected_index -= 1;
}
} }
} }
pub fn select_next_reaction(&mut self) { pub fn select_next_reaction(&mut self) {
if self.selected_reaction_index + 1 < self.available_reactions.len() { if let ChatState::ReactionPicker {
self.selected_reaction_index += 1; selected_index,
available_reactions,
..
} = &mut self.chat_state
{
if *selected_index + 1 < available_reactions.len() {
*selected_index += 1;
}
} }
} }
pub fn get_selected_reaction(&self) -> Option<&String> { pub fn get_selected_reaction(&self) -> Option<&String> {
self.available_reactions.get(self.selected_reaction_index) if let ChatState::ReactionPicker {
available_reactions,
selected_index,
..
} = &self.chat_state
{
available_reactions.get(*selected_index)
} else {
None
}
} }
pub fn get_selected_message_for_reaction(&self) -> Option<i64> { pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.selected_message_for_reaction self.chat_state.selected_message_id()
} }
} }

View File

@@ -114,16 +114,16 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.select_previous_profile_action(); app.select_previous_profile_action();
} }
KeyCode::Down => { KeyCode::Down => {
if let Some(profile) = &app.profile_info { if let Some(profile) = app.get_profile_info() {
let max_actions = get_available_actions_count(profile); let max_actions = get_available_actions_count(profile);
app.select_next_profile_action(max_actions); app.select_next_profile_action(max_actions);
} }
} }
KeyCode::Enter => { KeyCode::Enter => {
// Выполнить выбранное действие // Выполнить выбранное действие
if let Some(profile) = &app.profile_info { if let Some(profile) = app.get_profile_info() {
let actions = get_available_actions_count(profile); let actions = get_available_actions_count(profile);
let action_index = app.selected_profile_action; let action_index = app.get_selected_profile_action().unwrap_or(0);
if action_index < actions { if action_index < actions {
// Определяем какое действие выбрано // Определяем какое действие выбрано
@@ -201,14 +201,16 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
} }
KeyCode::Backspace => { KeyCode::Backspace => {
app.message_search_query.pop(); // Удаляем символ из запроса
if let Some(mut query) = app.get_search_query().map(|s| s.to_string()) {
query.pop();
app.update_search_query(query.clone());
// Выполняем поиск при изменении запроса // Выполняем поиск при изменении запроса
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
if !app.message_search_query.is_empty() { if !query.is_empty() {
if let Ok(Ok(results)) = timeout( if let Ok(Ok(results)) = timeout(
Duration::from_secs(3), Duration::from_secs(3),
app.td_client app.td_client.search_messages(chat_id, &query),
.search_messages(chat_id, &app.message_search_query),
) )
.await .await
{ {
@@ -219,14 +221,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
} }
} }
}
KeyCode::Char(c) => { KeyCode::Char(c) => {
app.message_search_query.push(c); // Добавляем символ к запросу
if let Some(mut query) = app.get_search_query().map(|s| s.to_string()) {
query.push(c);
app.update_search_query(query.clone());
// Выполняем поиск при изменении запроса // Выполняем поиск при изменении запроса
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
if let Ok(Ok(results)) = timeout( if let Ok(Ok(results)) = timeout(
Duration::from_secs(3), Duration::from_secs(3),
app.td_client app.td_client.search_messages(chat_id, &query),
.search_messages(chat_id, &app.message_search_query),
) )
.await .await
{ {
@@ -234,6 +239,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
} }
} }
}
_ => {} _ => {}
} }
return; return;
@@ -287,19 +293,32 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
KeyCode::Up => { KeyCode::Up => {
// Переход на ряд выше (8 эмодзи в ряду) // Переход на ряд выше (8 эмодзи в ряду)
if app.selected_reaction_index >= 8 { if let crate::app::ChatState::ReactionPicker {
app.selected_reaction_index = app.selected_reaction_index.saturating_sub(8); selected_index,
..
} = &mut app.chat_state
{
if *selected_index >= 8 {
*selected_index = selected_index.saturating_sub(8);
app.needs_redraw = true; app.needs_redraw = true;
} }
} }
}
KeyCode::Down => { KeyCode::Down => {
// Переход на ряд ниже (8 эмодзи в ряду) // Переход на ряд ниже (8 эмодзи в ряду)
let new_index = app.selected_reaction_index + 8; if let crate::app::ChatState::ReactionPicker {
if new_index < app.available_reactions.len() { selected_index,
app.selected_reaction_index = new_index; available_reactions,
..
} = &mut app.chat_state
{
let new_index = *selected_index + 8;
if new_index < available_reactions.len() {
*selected_index = new_index;
app.needs_redraw = true; app.needs_redraw = true;
} }
} }
}
KeyCode::Enter => { KeyCode::Enter => {
// Добавить/убрать реакцию // Добавить/убрать реакцию
if let Some(emoji) = app.get_selected_reaction().cloned() { if let Some(emoji) = app.get_selected_reaction().cloned() {
@@ -351,7 +370,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => {
// Подтверждение удаления // Подтверждение удаления
if let Some(msg_id) = app.confirm_delete_message_id { if let Some(msg_id) = app.chat_state.selected_message_id() {
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
// Находим сообщение для проверки can_be_deleted_for_all_users // Находим сообщение для проверки can_be_deleted_for_all_users
let can_delete_for_all = app let can_delete_for_all = app
@@ -377,7 +396,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.td_client app.td_client
.current_chat_messages .current_chat_messages
.retain(|m| m.id != msg_id); .retain(|m| m.id != msg_id);
app.selected_message_index = None; // Сбрасываем состояние
app.chat_state = crate::app::ChatState::Normal;
} }
Ok(Err(e)) => { Ok(Err(e)) => {
app.error_message = Some(e); app.error_message = Some(e);
@@ -388,11 +408,12 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
} }
} }
app.confirm_delete_message_id = None; // Закрываем модалку
app.chat_state = crate::app::ChatState::Normal;
} }
KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => {
// Отмена удаления // Отмена удаления
app.confirm_delete_message_id = None; app.chat_state = crate::app::ChatState::Normal;
} }
_ => {} _ => {}
} }
@@ -411,7 +432,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(i) = app.chat_list_state.selected() { if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) { if let Some(chat) = filtered.get(i) {
let to_chat_id = chat.id; let to_chat_id = chat.id;
if let Some(msg_id) = app.forwarding_message_id { if let Some(msg_id) = app.chat_state.selected_message_id() {
if let Some(from_chat_id) = app.get_selected_chat_id() { if let Some(from_chat_id) = app.get_selected_chat_id() {
match timeout( match timeout(
Duration::from_secs(5), Duration::from_secs(5),
@@ -528,7 +549,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Редактирование начато // Редактирование начато
} else { } else {
// Нельзя редактировать это сообщение // Нельзя редактировать это сообщение
app.selected_message_index = None; app.chat_state = crate::app::ChatState::Normal;
} }
return; return;
} }
@@ -538,11 +559,12 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
let text = app.message_input.clone(); let text = app.message_input.clone();
if let Some(msg_id) = app.editing_message_id { if let Some(msg_id) = app.chat_state.selected_message_id() {
if app.is_editing() {
// Режим редактирования // Режим редактирования
app.message_input.clear(); app.message_input.clear();
app.cursor_position = 0; app.cursor_position = 0;
app.editing_message_id = None; app.chat_state = crate::app::ChatState::Normal;
match timeout( match timeout(
Duration::from_secs(5), Duration::from_secs(5),
@@ -570,9 +592,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.error_message = Some("Таймаут редактирования".to_string()); app.error_message = Some("Таймаут редактирования".to_string());
} }
} }
}
} else { } else {
// Обычная отправка (или reply) // Обычная отправка (или reply)
let reply_to_id = app.replying_to_message_id; let reply_to_id = if app.is_replying() {
app.chat_state.selected_message_id()
} else {
None
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| { let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::client::ReplyInfo { crate::tdlib::client::ReplyInfo {
@@ -583,7 +610,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
}); });
app.message_input.clear(); app.message_input.clear();
app.cursor_position = 0; app.cursor_position = 0;
app.replying_to_message_id = None; // Сбрасываем режим reply если он был активен
if app.is_replying() {
app.chat_state = crate::app::ChatState::Normal;
}
app.last_typing_sent = None; app.last_typing_sent = None;
// Отменяем typing status // Отменяем typing status
@@ -665,7 +695,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if key.code == KeyCode::Esc { if key.code == KeyCode::Esc {
if app.is_selecting_message() { if app.is_selecting_message() {
// Отменить выбор сообщения // Отменить выбор сообщения
app.selected_message_index = None; app.chat_state = crate::app::ChatState::Normal;
} else if app.is_editing() { } else if app.is_editing() {
// Отменить редактирование // Отменить редактирование
app.cancel_editing(); app.cancel_editing();
@@ -709,7 +739,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
let can_delete = let can_delete =
msg.can_be_deleted_only_for_self || msg.can_be_deleted_for_all_users; msg.can_be_deleted_only_for_self || msg.can_be_deleted_for_all_users;
if can_delete { if can_delete {
app.confirm_delete_message_id = Some(msg.id); app.chat_state = crate::app::ChatState::DeleteConfirmation {
message_id: msg.id,
};
} }
} }
} }
@@ -789,8 +821,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await
{ {
Ok(Ok(profile)) => { Ok(Ok(profile)) => {
app.profile_info = Some(profile); app.enter_profile_mode(profile);
app.enter_profile_mode();
app.status_message = None; app.status_message = None;
} }
Ok(Err(e)) => { Ok(Err(e)) => {

View File

@@ -322,7 +322,7 @@ 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 app.is_profile_mode() {
if let Some(profile) = &app.profile_info { if let Some(profile) = app.get_profile_info() {
crate::ui::profile::render(f, area, app, profile); crate::ui::profile::render(f, area, app, profile);
} }
return; return;
@@ -964,18 +964,31 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
} }
// Модалка выбора реакции // Модалка выбора реакции
if app.is_reaction_picker_mode() { if let crate::app::ChatState::ReactionPicker {
render_reaction_picker_modal( available_reactions,
f, selected_index,
area, ..
&app.available_reactions, } = &app.chat_state
app.selected_reaction_index, {
); render_reaction_picker_modal(f, area, available_reactions, *selected_index);
} }
} }
/// Рендерит режим поиска по сообщениям /// Рендерит режим поиска по сообщениям
fn render_search_mode(f: &mut Frame, area: Rect, app: &App) { fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -986,14 +999,14 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
.split(area); .split(area);
// Search input // Search input
let total = app.message_search_results.len(); let total = results.len();
let current = if total > 0 { let current = if total > 0 {
app.selected_search_result_index + 1 selected_index + 1
} else { } else {
0 0
}; };
let input_line = if app.message_search_query.is_empty() { let input_line = if query.is_empty() {
Line::from(vec![ Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)), Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)), Span::styled("", Style::default().fg(Color::Yellow)),
@@ -1002,7 +1015,7 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
} else { } else {
Line::from(vec![ Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)), Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(&app.message_search_query, Style::default().fg(Color::White)), Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)), Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)), Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
]) ])
@@ -1025,16 +1038,16 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
let content_width = chunks[1].width.saturating_sub(2) as usize; let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
if app.message_search_results.is_empty() { if results.is_empty() {
if !app.message_search_query.is_empty() { if !query.is_empty() {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
"Ничего не найдено", "Ничего не найдено",
Style::default().fg(Color::Gray), Style::default().fg(Color::Gray),
))); )));
} }
} else { } else {
for (idx, msg) in app.message_search_results.iter().enumerate() { for (idx, msg) in results.iter().enumerate() {
let is_selected = idx == app.selected_search_result_index; let is_selected = idx == selected_index;
// Пустая строка между результатами // Пустая строка между результатами
if idx > 0 { if idx > 0 {
@@ -1101,7 +1114,7 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
// Скролл к выбранному результату // Скролл к выбранному результату
let visible_height = chunks[1].height.saturating_sub(2) as usize; let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_result = 4; let lines_per_result = 4;
let selected_line = app.selected_search_result_index * lines_per_result; let selected_line = selected_index * lines_per_result;
let scroll_offset = if selected_line > visible_height / 2 { let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16 (selected_line - visible_height / 2) as u16
} else { } else {
@@ -1158,6 +1171,17 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
/// Рендерит режим просмотра закреплённых сообщений /// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) { fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -1168,8 +1192,8 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
.split(area); .split(area);
// Header // Header
let total = app.pinned_messages.len(); let total = messages.len();
let current = app.selected_pinned_index + 1; let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total); let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text) let header = Paragraph::new(header_text)
.block( .block(
@@ -1188,8 +1212,8 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
let content_width = chunks[1].width.saturating_sub(2) as usize; let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in app.pinned_messages.iter().enumerate() { for (idx, msg) in messages.iter().enumerate() {
let is_selected = idx == app.selected_pinned_index; let is_selected = idx == selected_index;
// Пустая строка между сообщениями // Пустая строка между сообщениями
if idx > 0 { if idx > 0 {
@@ -1263,7 +1287,7 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
// Скролл к выбранному сообщению // Скролл к выбранному сообщению
let visible_height = chunks[1].height.saturating_sub(2) as usize; let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_msg = 5; // Примерно строк на сообщение let lines_per_msg = 5; // Примерно строк на сообщение
let selected_line = app.selected_pinned_index * lines_per_msg; let selected_line = selected_index * lines_per_msg;
let scroll_offset = if selected_line > visible_height / 2 { let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16 (selected_line - visible_height / 2) as u16
} else { } else {

View File

@@ -144,7 +144,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
let actions = get_available_actions(profile); let actions = get_available_actions(profile);
for (idx, action) in actions.iter().enumerate() { for (idx, action) in actions.iter().enumerate() {
let is_selected = idx == app.selected_profile_action; let is_selected = idx == app.get_selected_profile_action().unwrap_or(0);
let marker = if is_selected { "" } else { " " }; let marker = if is_selected { "" } else { " " };
let style = if is_selected { let style = if is_selected {
Style::default() Style::default()

View File

@@ -2,7 +2,7 @@
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use std::collections::HashMap; use std::collections::HashMap;
use tele_tui::app::{App, AppScreen}; use tele_tui::app::{App, AppScreen, ChatState};
use tele_tui::config::Config; use tele_tui::config::Config;
use tele_tui::tdlib::client::AuthState; use tele_tui::tdlib::client::AuthState;
use tele_tui::tdlib::{ChatInfo, MessageInfo}; use tele_tui::tdlib::{ChatInfo, MessageInfo};
@@ -21,17 +21,8 @@ pub struct TestAppBuilder {
message_input: String, message_input: String,
is_searching: bool, is_searching: bool,
search_query: String, search_query: String,
editing_message_id: Option<i64>, chat_state: Option<ChatState>,
replying_to_message_id: Option<i64>,
is_reaction_picker_mode: bool,
is_profile_mode: bool,
confirm_delete_message_id: Option<i64>,
messages: HashMap<i64, Vec<MessageInfo>>, messages: HashMap<i64, Vec<MessageInfo>>,
selected_message_index: Option<usize>,
message_search_mode: bool,
message_search_query: String,
forwarding_message_id: Option<i64>,
is_selecting_forward_chat: bool,
status_message: Option<String>, status_message: Option<String>,
auth_state: Option<AuthState>, auth_state: Option<AuthState>,
phone_input: Option<String>, phone_input: Option<String>,
@@ -55,17 +46,8 @@ impl TestAppBuilder {
message_input: String::new(), message_input: String::new(),
is_searching: false, is_searching: false,
search_query: String::new(), search_query: String::new(),
editing_message_id: None, chat_state: None,
replying_to_message_id: None,
is_reaction_picker_mode: false,
is_profile_mode: false,
confirm_delete_message_id: None,
messages: HashMap::new(), messages: HashMap::new(),
selected_message_index: None,
message_search_mode: false,
message_search_query: String::new(),
forwarding_message_id: None,
is_selecting_forward_chat: false,
status_message: None, status_message: None,
auth_state: None, auth_state: None,
phone_input: None, phone_input: None,
@@ -118,32 +100,43 @@ impl TestAppBuilder {
} }
/// Режим редактирования сообщения /// Режим редактирования сообщения
pub fn editing_message(mut self, message_id: i64) -> Self { pub fn editing_message(mut self, message_id: i64, selected_index: usize) -> Self {
self.editing_message_id = Some(message_id); self.chat_state = Some(ChatState::Editing {
message_id,
selected_index,
});
self self
} }
/// Режим ответа на сообщение /// Режим ответа на сообщение
pub fn replying_to(mut self, message_id: i64) -> Self { pub fn replying_to(mut self, message_id: i64) -> Self {
self.replying_to_message_id = Some(message_id); self.chat_state = Some(ChatState::Reply { message_id });
self self
} }
/// Режим выбора реакции /// Режим выбора реакции
pub fn reaction_picker(mut self) -> Self { pub fn reaction_picker(mut self, message_id: i64, available_reactions: Vec<String>) -> Self {
self.is_reaction_picker_mode = true; self.chat_state = Some(ChatState::ReactionPicker {
message_id,
available_reactions,
selected_index: 0,
});
self self
} }
/// Режим профиля /// Режим профиля
pub fn profile_mode(mut self) -> Self { pub fn profile_mode(mut self, info: tele_tui::tdlib::ProfileInfo) -> Self {
self.is_profile_mode = true; self.chat_state = Some(ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
});
self self
} }
/// Подтверждение удаления /// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self { pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.confirm_delete_message_id = Some(message_id); self.chat_state = Some(ChatState::DeleteConfirmation { message_id });
self self
} }
@@ -166,22 +159,27 @@ impl TestAppBuilder {
} }
/// Установить выбранное сообщение (режим selection) /// Установить выбранное сообщение (режим selection)
pub fn selecting_message(mut self, message_index: usize) -> Self { pub fn selecting_message(mut self, selected_index: usize) -> Self {
self.selected_message_index = Some(message_index); self.chat_state = Some(ChatState::MessageSelection { selected_index });
self self
} }
/// Режим поиска по сообщениям в чате /// Режим поиска по сообщениям в чате
pub fn message_search(mut self, query: &str) -> Self { pub fn message_search(mut self, query: &str) -> Self {
self.message_search_mode = true; self.chat_state = Some(ChatState::SearchInChat {
self.message_search_query = query.to_string(); query: query.to_string(),
results: Vec::new(),
selected_index: 0,
});
self self
} }
/// Режим пересылки сообщения /// Режим пересылки сообщения
pub fn forward_mode(mut self, message_id: i64) -> Self { pub fn forward_mode(mut self, message_id: i64) -> Self {
self.forwarding_message_id = Some(message_id); self.chat_state = Some(ChatState::Forward {
self.is_selecting_forward_chat = true; message_id,
selecting_chat: true,
});
self self
} }
@@ -229,16 +227,10 @@ impl TestAppBuilder {
app.message_input = self.message_input; app.message_input = self.message_input;
app.is_searching = self.is_searching; app.is_searching = self.is_searching;
app.search_query = self.search_query; app.search_query = self.search_query;
app.editing_message_id = self.editing_message_id; // Применяем chat_state если он установлен
app.replying_to_message_id = self.replying_to_message_id; if let Some(chat_state) = self.chat_state {
app.is_reaction_picker_mode = self.is_reaction_picker_mode; app.chat_state = chat_state;
app.is_profile_mode = self.is_profile_mode; }
app.confirm_delete_message_id = self.confirm_delete_message_id;
app.selected_message_index = self.selected_message_index;
app.is_message_search_mode = self.message_search_mode;
app.message_search_query = self.message_search_query;
app.forwarding_message_id = self.forwarding_message_id;
app.is_selecting_forward_chat = self.is_selecting_forward_chat;
// Применяем status_message // Применяем status_message
if let Some(status) = self.status_message { if let Some(status) = self.status_message {
@@ -325,11 +317,12 @@ mod tests {
#[test] #[test]
fn test_builder_editing_mode() { fn test_builder_editing_mode() {
let app = TestAppBuilder::new() let app = TestAppBuilder::new()
.editing_message(999) .editing_message(999, 0)
.message_input("Edited text") .message_input("Edited text")
.build(); .build();
assert_eq!(app.editing_message_id, Some(999)); assert!(app.is_editing());
assert_eq!(app.chat_state.selected_message_id(), Some(999));
assert_eq!(app.message_input, "Edited text"); assert_eq!(app.message_input, "Edited text");
} }

View File

@@ -95,7 +95,7 @@ fn snapshot_input_editing_mode() {
.with_chat(chat) .with_chat(chat)
.with_message(123, message) .with_message(123, message)
.selected_chat(123) .selected_chat(123)
.editing_message(1) .editing_message(1, 0)
.message_input("Edited text here") .message_input("Edited text here")
.build(); .build();

View File

@@ -34,11 +34,13 @@ fn snapshot_emoji_picker_default() {
let chat = create_test_chat("Mom", 123); let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("React to this", 1).build(); let message = TestMessageBuilder::new("React to this", 1).build();
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()];
let app = TestAppBuilder::new() let app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.with_message(123, message) .with_message(123, message)
.selected_chat(123) .selected_chat(123)
.reaction_picker() .reaction_picker(1, reactions)
.build(); .build();
let buffer = render_to_buffer(80, 24, |f| { let buffer = render_to_buffer(80, 24, |f| {
@@ -54,15 +56,19 @@ fn snapshot_emoji_picker_with_selection() {
let chat = create_test_chat("Mom", 123); let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("React to this", 1).build(); let message = TestMessageBuilder::new("React to this", 1).build();
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.with_message(123, message) .with_message(123, message)
.selected_chat(123) .selected_chat(123)
.reaction_picker() .reaction_picker(1, reactions)
.build(); .build();
// Выбираем 5-ю реакцию (индекс 4) // Выбираем 5-ю реакцию (индекс 4)
app.selected_reaction_index = 4; if let tele_tui::app::ChatState::ReactionPicker { selected_index, .. } = &mut app.chat_state {
*selected_index = 4;
}
let buffer = render_to_buffer(80, 24, |f| { let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app); tele_tui::ui::messages::render(f, f.area(), &app);
@@ -77,14 +83,12 @@ fn snapshot_profile_personal_chat() {
let chat = create_test_chat("Alice", 123); let chat = create_test_chat("Alice", 123);
let profile = create_test_profile("Alice", 123); let profile = create_test_profile("Alice", 123);
let mut app = TestAppBuilder::new() let app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.selected_chat(123) .selected_chat(123)
.profile_mode() .profile_mode(profile)
.build(); .build();
app.profile_info = Some(profile);
let buffer = render_to_buffer(80, 24, |f| { let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app); tele_tui::ui::messages::render(f, f.area(), &app);
}); });
@@ -103,14 +107,12 @@ fn snapshot_profile_group_chat() {
profile.member_count = Some(25); profile.member_count = Some(25);
profile.description = Some("Work discussion group".to_string()); profile.description = Some("Work discussion group".to_string());
let mut app = TestAppBuilder::new() let app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
.selected_chat(456) .selected_chat(456)
.profile_mode() .profile_mode(profile)
.build(); .build();
app.profile_info = Some(profile);
let buffer = render_to_buffer(80, 24, |f| { let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app); tele_tui::ui::messages::render(f, f.area(), &app);
}); });
@@ -157,8 +159,10 @@ fn snapshot_search_in_chat() {
.build(); .build();
// Устанавливаем результаты поиска // Устанавливаем результаты поиска
app.message_search_results = vec![msg1, msg2]; if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } = &mut app.chat_state {
app.selected_search_result_index = 0; *results = vec![msg1, msg2];
*selected_index = 0;
}
let buffer = render_to_buffer(80, 24, |f| { let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app); tele_tui::ui::messages::render(f, f.area(), &app);

View File

@@ -9,7 +9,7 @@ expression: output
│ ──────── 02.01.2022 ──────── │ │ ──────── 02.01.2022 ──────── │
│ │ │ │
│ Вы ──────────────── │ │ Вы ──────────────── │
Original message text (14:33 ✓✓) │ Original message text (14:33 ✓✓) │
│ │ │ │
│ │ │ │
│ │ │ │

View File

@@ -11,9 +11,9 @@ expression: output
│User ──────────────── │ │User ──────────────── │
│ (14:33) React to this │ │ (14:33) React to this │
│ │ │ │
│ │
│ ┌ Выбери реакцию ────────────────────────────────┐ │ │ ┌ Выбери реакцию ────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
│ │ │ │ │ │ │ │
│ └────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────┘ │
│ │ │ │

View File

@@ -11,9 +11,9 @@ expression: output
│User ──────────────── │ │User ──────────────── │
│ (14:33) React to this │ │ (14:33) React to this │
│ │ │ │
│ │
│ ┌ Выбери реакцию ────────────────────────────────┐ │ │ ┌ Выбери реакцию ────────────────────────────────┐ │
│ │ │ │ │ │ │ │
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
│ │ │ │ │ │ │ │
│ └────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────┘ │
│ │ │ │