Split modal and message rendering modules
This commit is contained in:
@@ -136,12 +136,12 @@ Target files:
|
|||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
|
|
||||||
- [ ] Split modal handlers by modal type and keep `modal.rs` as the dispatcher/module entry point.
|
- [x] Split modal handlers by modal type and keep `modal.rs` as the dispatcher/module entry point.
|
||||||
- [ ] Split message UI rendering into header, pinned-message, and list rendering modules.
|
- [x] Split message UI rendering into header, pinned-message, and list rendering modules.
|
||||||
- [ ] Keep public function names stable until each split is covered by tests.
|
- [x] Keep public function names stable until each split is covered by tests.
|
||||||
- [ ] Avoid mixing behavior changes with file movement.
|
- [x] Avoid mixing behavior changes with file movement.
|
||||||
- [ ] Run focused modal/navigation/message tests after each split.
|
- [x] Run focused modal/navigation/message tests after each split.
|
||||||
- [ ] Run `cargo test --all-features` after the full split.
|
- [x] Run `cargo test --all-features` after the full split.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
|
|||||||
@@ -1,405 +1,13 @@
|
|||||||
//! Modal dialog handlers
|
//! Modal dialog handlers.
|
||||||
//!
|
|
||||||
//! Handles keyboard input for modal dialogs, including:
|
|
||||||
//! - Account switcher (global overlay)
|
|
||||||
//! - Delete confirmation
|
|
||||||
//! - Reaction picker (emoji selector)
|
|
||||||
//! - Pinned messages view
|
|
||||||
//! - Profile information modal
|
|
||||||
|
|
||||||
use super::scroll_to_message;
|
mod account;
|
||||||
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
|
mod delete;
|
||||||
use crate::app::{AccountSwitcherState, App};
|
mod pinned;
|
||||||
use crate::input::handlers::get_available_actions_count;
|
mod profile;
|
||||||
use crate::tdlib::TdClientTrait;
|
mod reactions;
|
||||||
use crate::types::{ChatId, MessageId};
|
|
||||||
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Обработка ввода в модалке переключения аккаунтов
|
pub use account::handle_account_switcher;
|
||||||
///
|
pub use delete::handle_delete_confirmation;
|
||||||
/// **SelectAccount mode:**
|
pub use pinned::handle_pinned_mode;
|
||||||
/// - j/k (MoveUp/MoveDown) — навигация по списку
|
pub use profile::{handle_profile_mode, handle_profile_open};
|
||||||
/// - Enter — выбор аккаунта или переход к добавлению
|
pub use reactions::handle_reaction_picker_mode;
|
||||||
/// - a/ф — быстрое добавление аккаунта
|
|
||||||
/// - Esc — закрыть модалку
|
|
||||||
///
|
|
||||||
/// **AddAccount mode:**
|
|
||||||
/// - Char input → ввод имени
|
|
||||||
/// - Backspace → удалить символ
|
|
||||||
/// - Enter → создать аккаунт
|
|
||||||
/// - Esc → назад к списку
|
|
||||||
pub async fn handle_account_switcher<T: TdClientTrait>(
|
|
||||||
app: &mut App<T>,
|
|
||||||
key: KeyEvent,
|
|
||||||
command: Option<crate::config::Command>,
|
|
||||||
) {
|
|
||||||
let Some(state) = &app.account_switcher else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
match state {
|
|
||||||
AccountSwitcherState::SelectAccount { .. } => {
|
|
||||||
match command {
|
|
||||||
Some(crate::config::Command::MoveUp) => {
|
|
||||||
app.account_switcher_select_prev();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveDown) => {
|
|
||||||
app.account_switcher_select_next();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
|
||||||
app.account_switcher_confirm();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::Cancel) => {
|
|
||||||
app.close_account_switcher();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Raw key check for 'a'/'ф' shortcut
|
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
|
||||||
app.account_switcher_start_add();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AccountSwitcherState::AddAccount { .. } => match key.code {
|
|
||||||
KeyCode::Esc => {
|
|
||||||
app.account_switcher_back();
|
|
||||||
}
|
|
||||||
KeyCode::Enter => {
|
|
||||||
app.account_switcher_confirm_add();
|
|
||||||
}
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
if let Some(AccountSwitcherState::AddAccount {
|
|
||||||
name_input,
|
|
||||||
cursor_position,
|
|
||||||
error,
|
|
||||||
}) = &mut app.account_switcher
|
|
||||||
{
|
|
||||||
if *cursor_position > 0 {
|
|
||||||
let mut chars: Vec<char> = name_input.chars().collect();
|
|
||||||
chars.remove(*cursor_position - 1);
|
|
||||||
*name_input = chars.into_iter().collect();
|
|
||||||
*cursor_position -= 1;
|
|
||||||
*error = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
if let Some(AccountSwitcherState::AddAccount {
|
|
||||||
name_input,
|
|
||||||
cursor_position,
|
|
||||||
error,
|
|
||||||
}) = &mut app.account_switcher
|
|
||||||
{
|
|
||||||
let mut chars: Vec<char> = name_input.chars().collect();
|
|
||||||
chars.insert(*cursor_position, c);
|
|
||||||
*name_input = chars.into_iter().collect();
|
|
||||||
*cursor_position += 1;
|
|
||||||
*error = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка режима профиля пользователя/чата
|
|
||||||
///
|
|
||||||
/// Обрабатывает:
|
|
||||||
/// - Модалку подтверждения выхода из группы (двухшаговая)
|
|
||||||
/// - Навигацию по действиям профиля (Up/Down)
|
|
||||||
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
|
||||||
/// - Выход из режима профиля (Esc)
|
|
||||||
pub async fn handle_profile_mode<T: TdClientTrait>(
|
|
||||||
app: &mut App<T>,
|
|
||||||
key: KeyEvent,
|
|
||||||
command: Option<crate::config::Command>,
|
|
||||||
) {
|
|
||||||
// Обработка подтверждения выхода из группы
|
|
||||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
|
||||||
if confirmation_step > 0 {
|
|
||||||
match handle_yes_no(key.code) {
|
|
||||||
Some(true) => {
|
|
||||||
// Подтверждение
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(false) => {
|
|
||||||
// Отмена
|
|
||||||
app.cancel_leave_group();
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Другая клавиша - игнорируем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обычная навигация по профилю
|
|
||||||
match command {
|
|
||||||
Some(crate::config::Command::Cancel) => {
|
|
||||||
app.exit_profile_mode();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveUp) => {
|
|
||||||
app.select_previous_profile_action();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveDown) => {
|
|
||||||
if let Some(profile) = app.get_profile_info() {
|
|
||||||
let max_actions = get_available_actions_count(profile);
|
|
||||||
app.select_next_profile_action(max_actions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
|
||||||
// Выполнить выбранное действие
|
|
||||||
let Some(profile) = app.get_profile_info() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let actions = get_available_actions_count(profile);
|
|
||||||
let action_index = app.get_selected_profile_action().unwrap_or(0);
|
|
||||||
|
|
||||||
// Guard: проверяем, что индекс действия валидный
|
|
||||||
if action_index >= actions {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Определяем какое действие выбрано
|
|
||||||
let mut current_idx = 0;
|
|
||||||
|
|
||||||
// Действие: Открыть в браузере
|
|
||||||
if let Some(username) = &profile.username {
|
|
||||||
if action_index == current_idx {
|
|
||||||
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
|
||||||
#[cfg(feature = "url-open")]
|
|
||||||
{
|
|
||||||
match open::that(&url) {
|
|
||||||
Ok(_) => {
|
|
||||||
app.status_message = Some(format!("Открыто: {}", url));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message =
|
|
||||||
Some(format!("Ошибка открытия браузера: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "url-open"))]
|
|
||||||
{
|
|
||||||
app.error_message = Some(
|
|
||||||
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка Ctrl+I для открытия профиля чата/пользователя
|
|
||||||
///
|
|
||||||
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
|
||||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
|
||||||
let Some(chat_id) = app.selected_chat_id else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
app.status_message = Some("Загрузка профиля...".to_string());
|
|
||||||
match with_timeout_msg(
|
|
||||||
Duration::from_secs(5),
|
|
||||||
app.td_client.get_profile_info(chat_id),
|
|
||||||
"Таймаут загрузки профиля",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(profile) => {
|
|
||||||
app.enter_profile_mode(profile);
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(e);
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка модалки подтверждения удаления сообщения
|
|
||||||
///
|
|
||||||
/// Обрабатывает:
|
|
||||||
/// - Подтверждение удаления (Y/y/Д/д)
|
|
||||||
/// - Отмена удаления (N/n/Т/т)
|
|
||||||
/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users)
|
|
||||||
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
|
||||||
match handle_yes_no(key.code) {
|
|
||||||
Some(true) => {
|
|
||||||
// Подтверждение удаления
|
|
||||||
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
|
||||||
// Находим сообщение для проверки can_be_deleted_for_all_users
|
|
||||||
let can_delete_for_all = app
|
|
||||||
.td_client
|
|
||||||
.current_chat_messages()
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.id() == msg_id)
|
|
||||||
.map(|m| m.can_be_deleted_for_all_users())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
match with_timeout_msg(
|
|
||||||
Duration::from_secs(5),
|
|
||||||
app.td_client.delete_messages(
|
|
||||||
ChatId::new(chat_id),
|
|
||||||
vec![msg_id],
|
|
||||||
can_delete_for_all,
|
|
||||||
),
|
|
||||||
"Таймаут удаления",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
// Удаляем из локального списка
|
|
||||||
app.td_client
|
|
||||||
.update_current_chat_messages(|messages| {
|
|
||||||
messages.retain(|m| m.id() != msg_id);
|
|
||||||
});
|
|
||||||
// Сбрасываем состояние
|
|
||||||
app.chat_state = crate::app::ChatState::Normal;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Закрываем модалку
|
|
||||||
app.chat_state = crate::app::ChatState::Normal;
|
|
||||||
}
|
|
||||||
Some(false) => {
|
|
||||||
// Отмена удаления
|
|
||||||
app.chat_state = crate::app::ChatState::Normal;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Другая клавиша - игнорируем
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка режима выбора реакции (emoji picker)
|
|
||||||
///
|
|
||||||
/// Обрабатывает:
|
|
||||||
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
|
|
||||||
/// - Добавление/удаление реакции (Enter)
|
|
||||||
/// - Выход из режима (Esc)
|
|
||||||
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
|
|
||||||
app: &mut App<T>,
|
|
||||||
_key: KeyEvent,
|
|
||||||
command: Option<crate::config::Command>,
|
|
||||||
) {
|
|
||||||
match command {
|
|
||||||
Some(crate::config::Command::MoveLeft) => {
|
|
||||||
app.select_previous_reaction();
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveRight) => {
|
|
||||||
app.select_next_reaction();
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveUp) => {
|
|
||||||
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
|
|
||||||
&mut app.chat_state
|
|
||||||
{
|
|
||||||
if *selected_index >= 8 {
|
|
||||||
*selected_index = selected_index.saturating_sub(8);
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveDown) => {
|
|
||||||
if let crate::app::ChatState::ReactionPicker {
|
|
||||||
selected_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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
|
||||||
super::chat::send_reaction(app).await;
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::Cancel) => {
|
|
||||||
app.exit_reaction_picker_mode();
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка режима просмотра закреплённых сообщений
|
|
||||||
///
|
|
||||||
/// Обрабатывает:
|
|
||||||
/// - Навигацию по закреплённым сообщениям (Up/Down)
|
|
||||||
/// - Переход к сообщению в истории (Enter)
|
|
||||||
/// - Выход из режима (Esc)
|
|
||||||
pub async fn handle_pinned_mode<T: TdClientTrait>(
|
|
||||||
app: &mut App<T>,
|
|
||||||
_key: KeyEvent,
|
|
||||||
command: Option<crate::config::Command>,
|
|
||||||
) {
|
|
||||||
match command {
|
|
||||||
Some(crate::config::Command::Cancel) => {
|
|
||||||
app.exit_pinned_mode();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveUp) => {
|
|
||||||
app.select_previous_pinned();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::MoveDown) => {
|
|
||||||
app.select_next_pinned();
|
|
||||||
}
|
|
||||||
Some(crate::config::Command::SubmitMessage) => {
|
|
||||||
if let Some(msg_id) = app.get_selected_pinned_id() {
|
|
||||||
scroll_to_message(app, MessageId::new(msg_id));
|
|
||||||
app.exit_pinned_mode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
76
src/input/handlers/modal/account.rs
Normal file
76
src/input/handlers/modal/account.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use crate::app::{AccountSwitcherState, App};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
|
||||||
|
/// Обработка ввода в модалке переключения аккаунтов.
|
||||||
|
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
|
let Some(state) = &app.account_switcher else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match state {
|
||||||
|
AccountSwitcherState::SelectAccount { .. } => match command {
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.account_switcher_select_prev();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
app.account_switcher_confirm();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.close_account_switcher();
|
||||||
|
}
|
||||||
|
_ => match key.code {
|
||||||
|
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
||||||
|
app.account_switcher_start_add();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountSwitcherState::AddAccount { .. } => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.account_switcher_back();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.account_switcher_confirm_add();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if let Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
}) = &mut app.account_switcher
|
||||||
|
{
|
||||||
|
if *cursor_position > 0 {
|
||||||
|
let mut chars: Vec<char> = name_input.chars().collect();
|
||||||
|
chars.remove(*cursor_position - 1);
|
||||||
|
*name_input = chars.into_iter().collect();
|
||||||
|
*cursor_position -= 1;
|
||||||
|
*error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if let Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
}) = &mut app.account_switcher
|
||||||
|
{
|
||||||
|
let mut chars: Vec<char> = name_input.chars().collect();
|
||||||
|
chars.insert(*cursor_position, c);
|
||||||
|
*name_input = chars.into_iter().collect();
|
||||||
|
*cursor_position += 1;
|
||||||
|
*error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/input/handlers/modal/delete.rs
Normal file
52
src/input/handlers/modal/delete.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use crate::app::{App, ChatState};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::ChatId;
|
||||||
|
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Обработка модалки подтверждения удаления сообщения.
|
||||||
|
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||||
|
match handle_yes_no(key.code) {
|
||||||
|
Some(true) => {
|
||||||
|
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
let can_delete_for_all = app
|
||||||
|
.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id() == msg_id)
|
||||||
|
.map(|m| m.can_be_deleted_for_all_users())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.delete_messages(
|
||||||
|
ChatId::new(chat_id),
|
||||||
|
vec![msg_id],
|
||||||
|
can_delete_for_all,
|
||||||
|
),
|
||||||
|
"Таймаут удаления",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
app.td_client.update_current_chat_messages(|messages| {
|
||||||
|
messages.retain(|m| m.id() != msg_id);
|
||||||
|
});
|
||||||
|
app.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
Some(false) => {
|
||||||
|
app.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/input/handlers/modal/pinned.rs
Normal file
32
src/input/handlers/modal/pinned.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use crate::app::methods::modal::ModalMethods;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::input::handlers::scroll_to_message;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::MessageId;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
|
||||||
|
/// Обработка режима просмотра закреплённых сообщений.
|
||||||
|
pub async fn handle_pinned_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.select_previous_pinned();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.select_next_pinned();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||||
|
scroll_to_message(app, MessageId::new(msg_id));
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/input/handlers/modal/profile.rs
Normal file
136
src/input/handlers/modal/profile.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use crate::app::methods::modal::ModalMethods;
|
||||||
|
use crate::app::methods::navigation::NavigationMethods;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::input::handlers::get_available_actions_count;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Обработка режима профиля пользователя/чата.
|
||||||
|
pub async fn handle_profile_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
|
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||||
|
if confirmation_step > 0 {
|
||||||
|
match handle_yes_no(key.code) {
|
||||||
|
Some(true) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(false) => {
|
||||||
|
app.cancel_leave_group();
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_profile_mode();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.select_previous_profile_action();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
if let Some(profile) = app.get_profile_info() {
|
||||||
|
let max_actions = get_available_actions_count(profile);
|
||||||
|
app.select_next_profile_action(max_actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
let Some(profile) = app.get_profile_info() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let actions = get_available_actions_count(profile);
|
||||||
|
let action_index = app.get_selected_profile_action().unwrap_or(0);
|
||||||
|
if action_index >= actions {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut current_idx = 0;
|
||||||
|
|
||||||
|
if let Some(username) = &profile.username {
|
||||||
|
if action_index == current_idx {
|
||||||
|
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
||||||
|
#[cfg(feature = "url-open")]
|
||||||
|
{
|
||||||
|
match open::that(&url) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some(format!("Открыто: {}", url));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message =
|
||||||
|
Some(format!("Ошибка открытия браузера: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "url-open"))]
|
||||||
|
{
|
||||||
|
app.error_message = Some(
|
||||||
|
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка Ctrl+I для открытия профиля чата/пользователя.
|
||||||
|
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
let Some(chat_id) = app.selected_chat_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.status_message = Some("Загрузка профиля...".to_string());
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.get_profile_info(chat_id),
|
||||||
|
"Таймаут загрузки профиля",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(profile) => {
|
||||||
|
app.enter_profile_mode(profile);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/input/handlers/modal/reactions.rs
Normal file
54
src/input/handlers/modal/reactions.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use crate::app::methods::modal::ModalMethods;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
|
||||||
|
/// Обработка режима выбора реакции (emoji picker).
|
||||||
|
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
_key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::MoveLeft) => {
|
||||||
|
app.select_previous_reaction();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveRight) => {
|
||||||
|
app.select_next_reaction();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
|
||||||
|
&mut app.chat_state
|
||||||
|
{
|
||||||
|
if *selected_index >= 8 {
|
||||||
|
*selected_index = selected_index.saturating_sub(8);
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
if let crate::app::ChatState::ReactionPicker {
|
||||||
|
selected_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
crate::input::handlers::chat::send_reaction(app).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_reaction_picker_mode();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,408 +1,33 @@
|
|||||||
//! Chat message area rendering.
|
//! Chat message area rendering.
|
||||||
//!
|
|
||||||
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
|
|
||||||
//! to modals (search, pinned, reactions, delete) and compose_bar.
|
|
||||||
|
|
||||||
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
|
mod header;
|
||||||
|
mod list;
|
||||||
|
mod pinned;
|
||||||
|
|
||||||
|
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::message_grouping::{group_messages, MessageGroup};
|
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::ui::components;
|
|
||||||
use crate::ui::{compose_bar, modals};
|
use crate::ui::{compose_bar, modals};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Style},
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Рендерит заголовок чата с typing status
|
use header::render_chat_header;
|
||||||
fn render_chat_header<T: TdClientTrait>(
|
use list::render_message_list;
|
||||||
f: &mut Frame,
|
use pinned::render_pinned_bar;
|
||||||
area: Rect,
|
|
||||||
app: &App<T>,
|
|
||||||
chat: &crate::tdlib::ChatInfo,
|
|
||||||
) {
|
|
||||||
let typing_action = app
|
|
||||||
.td_client
|
|
||||||
.typing_status()
|
|
||||||
.as_ref()
|
|
||||||
.map(|(_, action, _)| action.clone());
|
|
||||||
|
|
||||||
let header_line = if let Some(action) = typing_action {
|
pub(crate) use list::wrap_text_with_offsets;
|
||||||
// Показываем typing status: "👤 Имя @username печатает..."
|
|
||||||
let mut spans = vec![Span::styled(
|
|
||||||
format!("👤 {}", chat.title),
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)];
|
|
||||||
if let Some(username) = &chat.username {
|
|
||||||
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
|
|
||||||
}
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" {}", action),
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::ITALIC),
|
|
||||||
));
|
|
||||||
Line::from(spans)
|
|
||||||
} else {
|
|
||||||
// Показываем username
|
|
||||||
let header_text = match &chat.username {
|
|
||||||
Some(username) => format!("👤 {} {}", chat.title, username),
|
|
||||||
None => format!("👤 {}", chat.title),
|
|
||||||
};
|
|
||||||
Line::from(Span::styled(
|
|
||||||
header_text,
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
))
|
|
||||||
};
|
|
||||||
|
|
||||||
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
|
|
||||||
f.render_widget(header, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит pinned bar с закреплённым сообщением
|
|
||||||
fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
let Some(pinned_msg) = app.td_client.current_pinned_message() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
|
|
||||||
let ellipsis = if pinned_msg.text().chars().count() > 40 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date());
|
|
||||||
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
|
|
||||||
let pinned_hint = "Ctrl+P";
|
|
||||||
|
|
||||||
let pinned_bar_width = area.width as usize;
|
|
||||||
let text_len = pinned_text.chars().count();
|
|
||||||
let hint_len = pinned_hint.chars().count();
|
|
||||||
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
|
|
||||||
|
|
||||||
let pinned_line = Line::from(vec![
|
|
||||||
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
|
|
||||||
Span::raw(" ".repeat(padding)),
|
|
||||||
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
|
||||||
]);
|
|
||||||
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
|
||||||
f.render_widget(pinned_bar, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
|
||||||
pub(super) struct WrappedLine {
|
|
||||||
pub text: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Разбивает текст на строки с учётом максимальной ширины
|
|
||||||
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
|
||||||
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|
||||||
if max_width == 0 {
|
|
||||||
return vec![WrappedLine { text: text.to_string() }];
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = Vec::new();
|
|
||||||
let mut current_line = String::new();
|
|
||||||
let mut current_width = 0;
|
|
||||||
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
let mut word_start = 0;
|
|
||||||
let mut in_word = false;
|
|
||||||
|
|
||||||
for (i, ch) in chars.iter().enumerate() {
|
|
||||||
if ch.is_whitespace() {
|
|
||||||
if in_word {
|
|
||||||
let word: String = chars[word_start..i].iter().collect();
|
|
||||||
let word_width = word.chars().count();
|
|
||||||
|
|
||||||
if current_width == 0 {
|
|
||||||
current_line = word;
|
|
||||||
current_width = word_width;
|
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
|
||||||
current_line.push(' ');
|
|
||||||
current_line.push_str(&word);
|
|
||||||
current_width += 1 + word_width;
|
|
||||||
} else {
|
|
||||||
result.push(WrappedLine { text: current_line });
|
|
||||||
current_line = word;
|
|
||||||
current_width = word_width;
|
|
||||||
}
|
|
||||||
in_word = false;
|
|
||||||
}
|
|
||||||
} else if !in_word {
|
|
||||||
word_start = i;
|
|
||||||
in_word = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if in_word {
|
|
||||||
let word: String = chars[word_start..].iter().collect();
|
|
||||||
let word_width = word.chars().count();
|
|
||||||
|
|
||||||
if current_width == 0 {
|
|
||||||
current_line = word;
|
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
|
||||||
current_line.push(' ');
|
|
||||||
current_line.push_str(&word);
|
|
||||||
} else {
|
|
||||||
result.push(WrappedLine { text: current_line });
|
|
||||||
current_line = word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !current_line.is_empty() {
|
|
||||||
result.push(WrappedLine { text: current_line });
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.is_empty() {
|
|
||||||
result.push(WrappedLine { text: String::new() });
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
|
|
||||||
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|
||||||
let content_width = area.width.saturating_sub(2) as usize;
|
|
||||||
|
|
||||||
// Messages с группировкой по дате и отправителю
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
|
|
||||||
// ID выбранного сообщения для подсветки
|
|
||||||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
|
||||||
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
|
||||||
let mut selected_msg_line: Option<usize> = None;
|
|
||||||
|
|
||||||
// ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений.
|
|
||||||
// Теперь загружаем только видимые изображения во втором проходе (см. ниже).
|
|
||||||
|
|
||||||
// Собираем информацию о развёрнутых изображениях (для второго прохода)
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
|
||||||
|
|
||||||
// Используем message_grouping для группировки сообщений
|
|
||||||
let current_messages = app.td_client.current_chat_messages();
|
|
||||||
let grouped = group_messages(¤t_messages);
|
|
||||||
let mut is_first_date = true;
|
|
||||||
let mut is_first_sender = true;
|
|
||||||
|
|
||||||
for group in grouped {
|
|
||||||
match group {
|
|
||||||
MessageGroup::DateSeparator(date) => {
|
|
||||||
// Рендерим разделитель даты
|
|
||||||
lines.extend(components::render_date_separator(
|
|
||||||
date,
|
|
||||||
content_width,
|
|
||||||
is_first_date,
|
|
||||||
));
|
|
||||||
is_first_date = false;
|
|
||||||
is_first_sender = true; // Сбрасываем счётчик заголовков после даты
|
|
||||||
}
|
|
||||||
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
|
|
||||||
// Рендерим заголовок отправителя
|
|
||||||
lines.extend(components::render_sender_header(
|
|
||||||
is_outgoing,
|
|
||||||
&sender_name,
|
|
||||||
content_width,
|
|
||||||
is_first_sender,
|
|
||||||
));
|
|
||||||
is_first_sender = false;
|
|
||||||
}
|
|
||||||
MessageGroup::Message(msg) => {
|
|
||||||
// Запоминаем строку начала выбранного сообщения
|
|
||||||
let is_selected = selected_msg_id == Some(msg.id());
|
|
||||||
if is_selected {
|
|
||||||
selected_msg_line = Some(lines.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рендерим сообщение
|
|
||||||
let bubble_lines = components::render_message_bubble(
|
|
||||||
msg,
|
|
||||||
app.config(),
|
|
||||||
content_width,
|
|
||||||
selected_msg_id,
|
|
||||||
app.playback_state.as_ref(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Собираем deferred image renders для всех загруженных фото
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
if let Some(photo) = msg.photo_info() {
|
|
||||||
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
|
|
||||||
&photo.download_state
|
|
||||||
{
|
|
||||||
let inline_width =
|
|
||||||
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
|
||||||
let img_height = components::calculate_image_height(
|
|
||||||
photo.width,
|
|
||||||
photo.height,
|
|
||||||
inline_width,
|
|
||||||
);
|
|
||||||
let img_width = inline_width as u16;
|
|
||||||
let bubble_len = bubble_lines.len();
|
|
||||||
let placeholder_start = lines.len() + bubble_len - img_height as usize;
|
|
||||||
|
|
||||||
deferred_images.push(components::DeferredImageRender {
|
|
||||||
message_id: msg.id(),
|
|
||||||
photo_path: path.clone(),
|
|
||||||
line_offset: placeholder_start,
|
|
||||||
x_offset: 0,
|
|
||||||
width: img_width,
|
|
||||||
height: img_height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.extend(bubble_lines);
|
|
||||||
}
|
|
||||||
MessageGroup::Album(album_messages) => {
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
{
|
|
||||||
let is_selected = album_messages
|
|
||||||
.iter()
|
|
||||||
.any(|m| selected_msg_id == Some(m.id()));
|
|
||||||
if is_selected {
|
|
||||||
selected_msg_line = Some(lines.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
|
||||||
&album_messages,
|
|
||||||
app.config(),
|
|
||||||
content_width,
|
|
||||||
selected_msg_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
for mut d in album_deferred {
|
|
||||||
d.line_offset += lines.len();
|
|
||||||
deferred_images.push(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.extend(bubble_lines);
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "images"))]
|
|
||||||
{
|
|
||||||
// Fallback: рендерим каждое сообщение отдельно
|
|
||||||
for msg in &album_messages {
|
|
||||||
let is_selected = selected_msg_id == Some(msg.id());
|
|
||||||
if is_selected {
|
|
||||||
selected_msg_line = Some(lines.len());
|
|
||||||
}
|
|
||||||
lines.extend(components::render_message_bubble(
|
|
||||||
msg,
|
|
||||||
app.config(),
|
|
||||||
content_width,
|
|
||||||
selected_msg_id,
|
|
||||||
app.playback_state.as_ref(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вычисляем скролл с учётом пользовательского offset
|
|
||||||
let visible_height = area.height.saturating_sub(2) as usize;
|
|
||||||
let total_lines = lines.len();
|
|
||||||
|
|
||||||
// Базовый скролл (показываем последние сообщения)
|
|
||||||
let base_scroll = total_lines.saturating_sub(visible_height);
|
|
||||||
|
|
||||||
// Если выбрано сообщение, автоскроллим к нему
|
|
||||||
let scroll_offset = if app.is_selecting_message() {
|
|
||||||
if let Some(selected_line) = selected_msg_line {
|
|
||||||
// Вычисляем нужный скролл, чтобы выбранное сообщение было видно
|
|
||||||
if selected_line < visible_height / 2 {
|
|
||||||
// Сообщение в начале — скроллим к началу
|
|
||||||
0
|
|
||||||
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
|
|
||||||
// Сообщение в конце — скроллим к концу
|
|
||||||
base_scroll
|
|
||||||
} else {
|
|
||||||
// Центрируем выбранное сообщение
|
|
||||||
selected_line.saturating_sub(visible_height / 2)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
base_scroll.saturating_sub(app.message_scroll_offset)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
base_scroll.saturating_sub(app.message_scroll_offset)
|
|
||||||
} as u16;
|
|
||||||
|
|
||||||
let messages_widget = Paragraph::new(lines)
|
|
||||||
.block(Block::default().borders(Borders::ALL))
|
|
||||||
.scroll((scroll_offset, 0));
|
|
||||||
f.render_widget(messages_widget, area);
|
|
||||||
|
|
||||||
// Второй проход: рендерим изображения поверх placeholder-ов
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
{
|
|
||||||
use ratatui_image::StatefulImage;
|
|
||||||
|
|
||||||
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
|
|
||||||
let should_render_images = app
|
|
||||||
.last_image_render_time
|
|
||||||
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
if !deferred_images.is_empty() && should_render_images {
|
|
||||||
let content_x = area.x + 1;
|
|
||||||
let content_y = area.y + 1;
|
|
||||||
|
|
||||||
for d in &deferred_images {
|
|
||||||
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
|
||||||
|
|
||||||
// Пропускаем изображения, которые полностью за пределами видимости
|
|
||||||
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let img_y = content_y + y_in_content as u16;
|
|
||||||
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
|
||||||
|
|
||||||
// ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание)
|
|
||||||
if d.height > remaining_height {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рендерим с ПОЛНОЙ высотой (не сжимаем)
|
|
||||||
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
|
||||||
|
|
||||||
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
|
|
||||||
// Используем inline_renderer с Halfblocks для скорости
|
|
||||||
if let Some(renderer) = &mut app.inline_image_renderer {
|
|
||||||
// Загружаем только если видимо (early return если уже в кеше)
|
|
||||||
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
|
||||||
|
|
||||||
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
|
||||||
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем время последнего рендеринга (для throttling)
|
|
||||||
app.last_image_render_time = Some(std::time::Instant::now());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||||
// Модальное окно просмотра изображения (приоритет выше всех)
|
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
if let Some(modal_state) = app.image_modal.clone() {
|
if let Some(modal_state) = app.image_modal.clone() {
|
||||||
modals::render_image_viewer(f, app, &modal_state);
|
modals::render_image_viewer(f, app, &modal_state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим профиля
|
|
||||||
if app.is_profile_mode() {
|
if app.is_profile_mode() {
|
||||||
if let Some(profile) = app.get_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);
|
||||||
@@ -410,65 +35,52 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим поиска по сообщениям
|
|
||||||
if app.is_message_search_mode() {
|
if app.is_message_search_mode() {
|
||||||
modals::render_search(f, area, app);
|
modals::render_search(f, area, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим просмотра закреплённых сообщений
|
|
||||||
if app.is_pinned_mode() {
|
if app.is_pinned_mode() {
|
||||||
modals::render_pinned(f, area, app);
|
modals::render_pinned(f, area, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(chat) = app.get_selected_chat().cloned() {
|
if let Some(chat) = app.get_selected_chat().cloned() {
|
||||||
// Вычисляем динамическую высоту инпута на основе длины текста
|
let input_width = area.width.saturating_sub(4) as usize;
|
||||||
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
|
||||||
let input_lines: u16 = if input_width > 0 {
|
let input_lines: u16 = if input_width > 0 {
|
||||||
let len = app.message_input.chars().count() + 2; // +2 для "> "
|
let len = app.message_input.chars().count() + 2;
|
||||||
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
};
|
};
|
||||||
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
|
|
||||||
let input_height = (input_lines + 2).clamp(3, 10);
|
let input_height = (input_lines + 2).clamp(3, 10);
|
||||||
|
|
||||||
// Проверяем, есть ли закреплённое сообщение
|
|
||||||
let has_pinned = app.td_client.current_pinned_message().is_some();
|
let has_pinned = app.td_client.current_pinned_message().is_some();
|
||||||
|
|
||||||
let message_chunks = if has_pinned {
|
let message_chunks = if has_pinned {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3), // Chat header
|
Constraint::Length(3),
|
||||||
Constraint::Length(1), // Pinned bar
|
Constraint::Length(1),
|
||||||
Constraint::Min(0), // Messages
|
Constraint::Min(0),
|
||||||
Constraint::Length(input_height), // Input box (динамическая высота)
|
Constraint::Length(input_height),
|
||||||
])
|
])
|
||||||
.split(area)
|
.split(area)
|
||||||
} else {
|
} else {
|
||||||
Layout::default()
|
Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(3), // Chat header
|
Constraint::Length(3),
|
||||||
Constraint::Length(0), // Pinned bar (hidden)
|
Constraint::Length(0),
|
||||||
Constraint::Min(0), // Messages
|
Constraint::Min(0),
|
||||||
Constraint::Length(input_height), // Input box (динамическая высота)
|
Constraint::Length(input_height),
|
||||||
])
|
])
|
||||||
.split(area)
|
.split(area)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chat header с typing status
|
|
||||||
render_chat_header(f, message_chunks[0], app, &chat);
|
render_chat_header(f, message_chunks[0], app, &chat);
|
||||||
|
|
||||||
// Pinned bar (если есть закреплённое сообщение)
|
|
||||||
render_pinned_bar(f, message_chunks[1], app);
|
render_pinned_bar(f, message_chunks[1], app);
|
||||||
|
|
||||||
// Messages с группировкой по дате и отправителю
|
|
||||||
render_message_list(f, message_chunks[2], app);
|
render_message_list(f, message_chunks[2], app);
|
||||||
|
|
||||||
// Input box с wrap для длинного текста и блочным курсором
|
|
||||||
compose_bar::render(f, message_chunks[3], app);
|
compose_bar::render(f, message_chunks[3], app);
|
||||||
} else {
|
} else {
|
||||||
let empty = Paragraph::new("Выберите чат")
|
let empty = Paragraph::new("Выберите чат")
|
||||||
@@ -478,12 +90,10 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
f.render_widget(empty, area);
|
f.render_widget(empty, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модалка подтверждения удаления
|
|
||||||
if app.is_confirm_delete_shown() {
|
if app.is_confirm_delete_shown() {
|
||||||
modals::render_delete_confirm(f, area);
|
modals::render_delete_confirm(f, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модалка выбора реакции
|
|
||||||
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
|
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
|
||||||
&app.chat_state
|
&app.chat_state
|
||||||
{
|
{
|
||||||
|
|||||||
55
src/ui/messages/header.rs
Normal file
55
src/ui/messages/header.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::{ChatInfo, TdClientTrait};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn render_chat_header<T: TdClientTrait>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
app: &App<T>,
|
||||||
|
chat: &ChatInfo,
|
||||||
|
) {
|
||||||
|
let typing_action = app
|
||||||
|
.td_client
|
||||||
|
.typing_status()
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, action, _)| action.clone());
|
||||||
|
|
||||||
|
let header_line = if let Some(action) = typing_action {
|
||||||
|
let mut spans = vec![Span::styled(
|
||||||
|
format!("👤 {}", chat.title),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)];
|
||||||
|
if let Some(username) = &chat.username {
|
||||||
|
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" {}", action),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
));
|
||||||
|
Line::from(spans)
|
||||||
|
} else {
|
||||||
|
let header_text = match &chat.username {
|
||||||
|
Some(username) => format!("👤 {} {}", chat.title, username),
|
||||||
|
None => format!("👤 {}", chat.title),
|
||||||
|
};
|
||||||
|
Line::from(Span::styled(
|
||||||
|
header_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
|
||||||
|
f.render_widget(header, area);
|
||||||
|
}
|
||||||
286
src/ui/messages/list.rs
Normal file
286
src/ui/messages/list.rs
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
use crate::app::methods::messages::MessageMethods;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::message_grouping::{group_messages, MessageGroup};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::ui::components;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Информация о строке после переноса: текст и позиция в оригинале.
|
||||||
|
pub(crate) struct WrappedLine {
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбивает текст на строки с учётом максимальной ширины.
|
||||||
|
pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||||
|
if max_width == 0 {
|
||||||
|
return vec![WrappedLine { text: text.to_string() }];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut current_line = String::new();
|
||||||
|
let mut current_width = 0;
|
||||||
|
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut word_start = 0;
|
||||||
|
let mut in_word = false;
|
||||||
|
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
if in_word {
|
||||||
|
let word: String = chars[word_start..i].iter().collect();
|
||||||
|
let word_width = word.chars().count();
|
||||||
|
|
||||||
|
if current_width == 0 {
|
||||||
|
current_line = word;
|
||||||
|
current_width = word_width;
|
||||||
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
|
current_line.push(' ');
|
||||||
|
current_line.push_str(&word);
|
||||||
|
current_width += 1 + word_width;
|
||||||
|
} else {
|
||||||
|
result.push(WrappedLine { text: current_line });
|
||||||
|
current_line = word;
|
||||||
|
current_width = word_width;
|
||||||
|
}
|
||||||
|
in_word = false;
|
||||||
|
}
|
||||||
|
} else if !in_word {
|
||||||
|
word_start = i;
|
||||||
|
in_word = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in_word {
|
||||||
|
let word: String = chars[word_start..].iter().collect();
|
||||||
|
let word_width = word.chars().count();
|
||||||
|
|
||||||
|
if current_width == 0 {
|
||||||
|
current_line = word;
|
||||||
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
|
current_line.push(' ');
|
||||||
|
current_line.push_str(&word);
|
||||||
|
} else {
|
||||||
|
result.push(WrappedLine { text: current_line });
|
||||||
|
current_line = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current_line.is_empty() {
|
||||||
|
result.push(WrappedLine { text: current_line });
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.is_empty() {
|
||||||
|
result.push(WrappedLine { text: String::new() });
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом.
|
||||||
|
pub(super) fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||||
|
let content_width = area.width.saturating_sub(2) as usize;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||||||
|
let mut selected_msg_line: Option<usize> = None;
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||||||
|
|
||||||
|
let current_messages = app.td_client.current_chat_messages();
|
||||||
|
let grouped = group_messages(¤t_messages);
|
||||||
|
let mut is_first_date = true;
|
||||||
|
let mut is_first_sender = true;
|
||||||
|
|
||||||
|
for group in grouped {
|
||||||
|
match group {
|
||||||
|
MessageGroup::DateSeparator(date) => {
|
||||||
|
lines.extend(components::render_date_separator(date, content_width, is_first_date));
|
||||||
|
is_first_date = false;
|
||||||
|
is_first_sender = true;
|
||||||
|
}
|
||||||
|
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
|
||||||
|
lines.extend(components::render_sender_header(
|
||||||
|
is_outgoing,
|
||||||
|
&sender_name,
|
||||||
|
content_width,
|
||||||
|
is_first_sender,
|
||||||
|
));
|
||||||
|
is_first_sender = false;
|
||||||
|
}
|
||||||
|
MessageGroup::Message(msg) => {
|
||||||
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let bubble_lines = components::render_message_bubble(
|
||||||
|
msg,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
|
||||||
|
&photo.download_state
|
||||||
|
{
|
||||||
|
let inline_width =
|
||||||
|
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||||
|
let img_height = components::calculate_image_height(
|
||||||
|
photo.width,
|
||||||
|
photo.height,
|
||||||
|
inline_width,
|
||||||
|
);
|
||||||
|
let img_width = inline_width as u16;
|
||||||
|
let bubble_len = bubble_lines.len();
|
||||||
|
let placeholder_start = lines.len() + bubble_len - img_height as usize;
|
||||||
|
|
||||||
|
deferred_images.push(components::DeferredImageRender {
|
||||||
|
message_id: msg.id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
line_offset: placeholder_start,
|
||||||
|
x_offset: 0,
|
||||||
|
width: img_width,
|
||||||
|
height: img_height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
MessageGroup::Album(album_messages) => {
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
let is_selected = album_messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
||||||
|
&album_messages,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
for mut d in album_deferred {
|
||||||
|
d.line_offset += lines.len();
|
||||||
|
deferred_images.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "images"))]
|
||||||
|
{
|
||||||
|
for msg in &album_messages {
|
||||||
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
lines.extend(components::render_message_bubble(
|
||||||
|
msg,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible_height = area.height.saturating_sub(2) as usize;
|
||||||
|
let total_lines = lines.len();
|
||||||
|
let base_scroll = total_lines.saturating_sub(visible_height);
|
||||||
|
|
||||||
|
let scroll_offset = if app.is_selecting_message() {
|
||||||
|
if let Some(selected_line) = selected_msg_line {
|
||||||
|
if selected_line < visible_height / 2 {
|
||||||
|
0
|
||||||
|
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
|
||||||
|
base_scroll
|
||||||
|
} else {
|
||||||
|
selected_line.saturating_sub(visible_height / 2)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base_scroll.saturating_sub(app.message_scroll_offset)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
base_scroll.saturating_sub(app.message_scroll_offset)
|
||||||
|
} as u16;
|
||||||
|
|
||||||
|
let messages_widget = Paragraph::new(lines)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.scroll((scroll_offset, 0));
|
||||||
|
f.render_widget(messages_widget, area);
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
fn render_deferred_images<T: TdClientTrait>(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
app: &mut App<T>,
|
||||||
|
deferred_images: &[components::DeferredImageRender],
|
||||||
|
visible_height: usize,
|
||||||
|
scroll_offset: u16,
|
||||||
|
) {
|
||||||
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
let should_render_images = app
|
||||||
|
.last_image_render_time
|
||||||
|
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if deferred_images.is_empty() || !should_render_images {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_x = area.x + 1;
|
||||||
|
let content_y = area.y + 1;
|
||||||
|
|
||||||
|
for d in deferred_images {
|
||||||
|
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
||||||
|
|
||||||
|
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let img_y = content_y + y_in_content as u16;
|
||||||
|
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
||||||
|
|
||||||
|
if d.height > remaining_height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
||||||
|
|
||||||
|
if let Some(renderer) = &mut app.inline_image_renderer {
|
||||||
|
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
||||||
|
|
||||||
|
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||||
|
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.last_image_render_time = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
38
src/ui/messages/pinned.rs
Normal file
38
src/ui/messages/pinned.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::Paragraph,
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
let Some(pinned_msg) = app.td_client.current_pinned_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
|
||||||
|
let ellipsis = if pinned_msg.text().chars().count() > 40 {
|
||||||
|
"..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date());
|
||||||
|
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
|
||||||
|
let pinned_hint = "Ctrl+P";
|
||||||
|
|
||||||
|
let pinned_bar_width = area.width as usize;
|
||||||
|
let text_len = pinned_text.chars().count();
|
||||||
|
let hint_len = pinned_hint.chars().count();
|
||||||
|
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
|
||||||
|
|
||||||
|
let pinned_line = Line::from(vec![
|
||||||
|
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
||||||
|
f.render_widget(pinned_bar, area);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user