This commit is contained in:
Mikhail Kilin
2026-01-24 18:53:35 +03:00
parent 22c4e17377
commit fa749d24c5
7 changed files with 576 additions and 29 deletions

View File

@@ -32,6 +32,9 @@
- **Отправка текстовых сообщений** - **Отправка текстовых сообщений**
- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование - **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения - **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения
- **Reply на сообщения**: в режиме выбора нажать `r` / `к` → режим ответа с превью
- **Forward сообщений**: в режиме выбора нажать `f` / `а` → выбор чата для пересылки
- **Отображение пересланных сообщений**: индикатор "↪ Переслано от" с именем отправителя
- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений - **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений
- **Новые сообщения в реальном времени** при открытом чате - **Новые сообщения в реальном времени** при открытом чате
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username - **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
@@ -78,10 +81,12 @@
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых) - `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
- `↑` при пустом инпуте — выбор сообщения для редактирования - `↑` при пустом инпуте — выбор сообщения для редактирования
- `Enter` в режиме выбора — начать редактирование - `Enter` в режиме выбора — начать редактирование
- `r` / `к` в режиме выбора — ответить на сообщение (reply)
- `f` / `а` в режиме выбора — переслать сообщение (forward)
- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением) - `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением)
- `y` / `н` / `Enter` — подтвердить удаление в модалке - `y` / `н` / `Enter` — подтвердить удаление в модалке
- `n` / `т` / `Esc` — отменить удаление в модалке - `n` / `т` / `Esc` — отменить удаление в модалке
- `Esc` — отменить выбор/редактирование - `Esc` — отменить выбор/редактирование/reply
- `1-9` — переключение папок (в списке чатов) - `1-9` — переключение папок (в списке чатов)
- **Редактирование текста в инпуте:** - **Редактирование текста в инпуте:**
- `←` / `→` — перемещение курсора - `←` / `→` — перемещение курсора
@@ -166,8 +171,8 @@ API_HASH=your_api_hash
- [x] Markdown форматирование в сообщениях - [x] Markdown форматирование в сообщениях
- [x] Редактирование сообщений - [x] Редактирование сообщений
- [x] Удаление сообщений - [x] Удаление сообщений
- [ ] Reply на сообщения - [x] Reply на сообщения
- [ ] Forward сообщений - [x] Forward сообщений
## Известные проблемы ## Известные проблемы

View File

@@ -101,5 +101,13 @@
- Vim-style курсор █ - Vim-style курсор █
- Перемещение ←/→, Home/End - Перемещение ←/→, Home/End
- Редактирование в любой позиции - Редактирование в любой позиции
- [ ] Reply на сообщения - [x] Reply на сообщения
- [ ] Forward сообщений - `r` / `к` в режиме выбора → режим ответа
- Превью сообщения в поле ввода
- Esc для отмены
- [x] Forward сообщений
- `f` / `а` в режиме выбора → режим пересылки
- Превью сообщения в поле ввода
- Выбор чата стрелками, Enter для пересылки
- Esc для отмены
- Отображение "↪ Переслано от" для пересланных сообщений

View File

@@ -39,6 +39,14 @@ pub struct App {
// Delete confirmation // Delete confirmation
/// ID сообщения для подтверждения удаления (показывает модалку) /// ID сообщения для подтверждения удаления (показывает модалку)
pub confirm_delete_message_id: Option<i64>, 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,
} }
impl App { impl App {
@@ -68,6 +76,9 @@ impl App {
editing_message_id: None, editing_message_id: None,
selected_message_index: None, selected_message_index: None,
confirm_delete_message_id: None, confirm_delete_message_id: None,
replying_to_message_id: None,
forwarding_message_id: None,
is_selecting_forward_chat: false,
} }
} }
@@ -123,6 +134,7 @@ impl App {
self.message_scroll_offset = 0; self.message_scroll_offset = 0;
self.editing_message_id = None; self.editing_message_id = None;
self.selected_message_index = None; self.selected_message_index = None;
self.replying_to_message_id = None;
// Очищаем данные в 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();
@@ -306,4 +318,62 @@ 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.confirm_delete_message_id.is_some()
} }
/// Начать режим ответа на выбранное сообщение
pub fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.replying_to_message_id = Some(msg.id);
self.selected_message_index = None;
return true;
}
false
}
/// Отменить режим ответа
pub fn cancel_reply(&mut self) {
self.replying_to_message_id = None;
}
/// Проверить, находимся ли в режиме ответа
pub fn is_replying(&self) -> bool {
self.replying_to_message_id.is_some()
}
/// Получить сообщение, на которое отвечаем
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.replying_to_message_id.and_then(|id| {
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
})
}
/// Начать режим пересылки выбранного сообщения
pub fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.forwarding_message_id = Some(msg.id);
self.selected_message_index = None;
self.is_selecting_forward_chat = true;
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;
}
false
}
/// Отменить режим пересылки
pub fn cancel_forward(&mut self) {
self.forwarding_message_id = None;
self.is_selecting_forward_chat = false;
}
/// Проверить, находимся ли в режиме выбора чата для пересылки
pub fn is_forwarding(&self) -> bool {
self.is_selecting_forward_chat && self.forwarding_message_id.is_some()
}
/// Получить сообщение для пересылки
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.forwarding_message_id.and_then(|id| {
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
})
}
} }

View File

@@ -67,6 +67,51 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return; return;
} }
// Режим выбора чата для пересылки
if app.is_forwarding() {
match key.code {
KeyCode::Esc => {
app.cancel_forward();
}
KeyCode::Enter => {
// Выбираем чат и пересылаем сообщение
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
let to_chat_id = chat.id;
if let Some(msg_id) = app.forwarding_message_id {
if let Some(from_chat_id) = app.get_selected_chat_id() {
match timeout(
Duration::from_secs(5),
app.td_client.forward_messages(to_chat_id, from_chat_id, vec![msg_id])
).await {
Ok(Ok(_)) => {
app.status_message = Some("Сообщение переслано".to_string());
}
Ok(Err(e)) => {
app.error_message = Some(e);
}
Err(_) => {
app.error_message = Some("Таймаут пересылки".to_string());
}
}
}
}
}
}
app.cancel_forward();
}
KeyCode::Down => {
app.next_chat();
}
KeyCode::Up => {
app.previous_chat();
}
_ => {}
}
return;
}
// Режим поиска // Режим поиска
if app.is_searching { if app.is_searching {
match key.code { match key.code {
@@ -81,7 +126,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset = 0; app.message_scroll_offset = 0;
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
Ok(Ok(_)) => { Ok(Ok(_)) => {
// Сообщения уже сохранены в td_client.current_chat_messages // Загружаем недостающие reply info
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
app.status_message = None; app.status_message = None;
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -161,11 +207,21 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
} }
} else { } else {
// Обычная отправка // Обычная отправка (или reply)
let reply_to_id = app.replying_to_message_id;
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::client::ReplyInfo {
message_id: m.id,
sender_name: m.sender_name.clone(),
text: m.content.clone(),
}
});
app.message_input.clear(); app.message_input.clear();
app.cursor_position = 0; app.cursor_position = 0;
app.replying_to_message_id = None;
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text)).await { match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await {
Ok(Ok(sent_msg)) => { Ok(Ok(sent_msg)) => {
// Добавляем отправленное сообщение в список (с лимитом) // Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg); app.td_client.push_message(sent_msg);
@@ -193,7 +249,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset = 0; app.message_scroll_offset = 0;
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
Ok(Ok(_)) => { Ok(Ok(_)) => {
// Сообщения уже сохранены в td_client.current_chat_messages // Загружаем недостающие reply info
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
app.status_message = None; app.status_message = None;
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@@ -211,7 +268,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return; return;
} }
// Esc - отменить выбор/редактирование или закрыть чат // Esc - отменить выбор/редактирование/reply или закрыть чат
if key.code == KeyCode::Esc { if key.code == KeyCode::Esc {
if app.is_selecting_message() { if app.is_selecting_message() {
// Отменить выбор сообщения // Отменить выбор сообщения
@@ -219,6 +276,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} else if app.is_editing() { } else if app.is_editing() {
// Отменить редактирование // Отменить редактирование
app.cancel_editing(); app.cancel_editing();
} else if app.is_replying() {
// Отменить режим ответа
app.cancel_reply();
} else if app.selected_chat_id.is_some() { } else if app.selected_chat_id.is_some() {
app.close_chat(); app.close_chat();
} }
@@ -246,6 +306,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
} }
} }
} }
KeyCode::Char('r') | KeyCode::Char('к') => {
// Начать режим ответа на выбранное сообщение
app.start_reply_to_selected();
}
KeyCode::Char('f') | KeyCode::Char('а') => {
// Начать режим пересылки
app.start_forward_selected();
}
_ => {} _ => {}
} }
return; return;

View File

@@ -113,6 +113,26 @@ pub struct ChatInfo {
pub is_muted: bool, pub is_muted: bool,
} }
/// Информация о сообщении, на которое отвечают
#[derive(Debug, Clone)]
pub struct ReplyInfo {
/// ID сообщения, на которое отвечают
pub message_id: i64,
/// Имя отправителя оригинального сообщения
pub sender_name: String,
/// Текст оригинального сообщения (превью)
pub text: String,
}
/// Информация о пересланном сообщении
#[derive(Debug, Clone)]
pub struct ForwardInfo {
/// Имя оригинального отправителя
pub sender_name: String,
/// Дата оригинального сообщения
pub date: i32,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessageInfo { pub struct MessageInfo {
pub id: i64, pub id: i64,
@@ -131,6 +151,10 @@ pub struct MessageInfo {
pub can_be_deleted_only_for_self: bool, pub can_be_deleted_only_for_self: bool,
/// Можно ли удалить для всех /// Можно ли удалить для всех
pub can_be_deleted_for_all_users: bool, pub can_be_deleted_for_all_users: bool,
/// Информация о reply (если это ответ на сообщение)
pub reply_to: Option<ReplyInfo>,
/// Информация о forward (если сообщение переслано)
pub forward_from: Option<ForwardInfo>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -240,7 +264,17 @@ impl TdClient {
} }
/// Добавляет сообщение в текущий чат с соблюдением лимита /// Добавляет сообщение в текущий чат с соблюдением лимита
/// Если сообщение с таким id уже есть — заменяет его (сохраняя reply_to)
pub fn push_message(&mut self, msg: MessageInfo) { pub fn push_message(&mut self, msg: MessageInfo) {
// Проверяем, есть ли уже сообщение с таким id
if let Some(idx) = self.current_chat_messages.iter().position(|m| m.id == msg.id) {
// Если новое сообщение имеет reply_to, или старое не имеет — заменяем
if msg.reply_to.is_some() || self.current_chat_messages[idx].reply_to.is_none() {
self.current_chat_messages[idx] = msg;
}
return;
}
self.current_chat_messages.push(msg); self.current_chat_messages.push(msg);
// Ограничиваем количество сообщений (удаляем старые) // Ограничиваем количество сообщений (удаляем старые)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT { if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
@@ -389,12 +423,25 @@ impl TdClient {
let msg_info = self.convert_message(&new_msg.message, chat_id); let msg_info = self.convert_message(&new_msg.message, chat_id);
let msg_id = msg_info.id; let msg_id = msg_info.id;
let is_incoming = !msg_info.is_outgoing; let is_incoming = !msg_info.is_outgoing;
// Проверяем, что сообщение ещё не добавлено (по id)
if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) { // Проверяем, есть ли уже сообщение с таким id
self.push_message(msg_info); let existing_idx = self.current_chat_messages.iter().position(|m| m.id == msg_info.id);
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming { match existing_idx {
self.pending_view_messages.push((chat_id, vec![msg_id])); Some(idx) => {
// Сообщение уже есть - обновляем только если входящее
// (исходящие уже добавлены через send_message с правильным reply_to)
if is_incoming {
self.current_chat_messages[idx] = msg_info;
}
}
None => {
// Нового сообщения нет - добавляем
self.push_message(msg_info);
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming {
self.pending_view_messages.push((chat_id, vec![msg_id]));
}
} }
} }
} }
@@ -630,6 +677,12 @@ impl TdClient {
let (content, entities) = extract_message_text_static(message); let (content, entities) = extract_message_text_static(message);
// Извлекаем информацию о reply
let reply_to = self.extract_reply_info(message);
// Извлекаем информацию о forward
let forward_from = self.extract_forward_info(message);
MessageInfo { MessageInfo {
id: message.id, id: message.id,
sender_name, sender_name,
@@ -642,6 +695,187 @@ impl TdClient {
can_be_edited: message.can_be_edited, can_be_edited: message.can_be_edited,
can_be_deleted_only_for_self: message.can_be_deleted_only_for_self, can_be_deleted_only_for_self: message.can_be_deleted_only_for_self,
can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, can_be_deleted_for_all_users: message.can_be_deleted_for_all_users,
reply_to,
forward_from,
}
}
/// Извлекает информацию о reply из сообщения
fn extract_reply_info(&self, message: &TdMessage) -> Option<ReplyInfo> {
use tdlib_rs::enums::MessageReplyTo;
match &message.reply_to {
Some(MessageReplyTo::Message(reply)) => {
// Получаем имя отправителя из origin или ищем сообщение в текущем списке
let sender_name = if let Some(origin) = &reply.origin {
self.get_origin_sender_name(origin)
} else {
// Пробуем найти оригинальное сообщение в текущем списке
self.current_chat_messages
.iter()
.find(|m| m.id == reply.message_id)
.map(|m| m.sender_name.clone())
.unwrap_or_else(|| "...".to_string())
};
// Получаем текст из content или quote
let text = if let Some(quote) = &reply.quote {
quote.text.text.clone()
} else if let Some(content) = &reply.content {
extract_content_text(content)
} else {
// Пробуем найти в текущих сообщениях
self.current_chat_messages
.iter()
.find(|m| m.id == reply.message_id)
.map(|m| m.content.clone())
.unwrap_or_default()
};
Some(ReplyInfo {
message_id: reply.message_id,
sender_name,
text,
})
}
_ => None,
}
}
/// Извлекает информацию о forward из сообщения
fn extract_forward_info(&self, message: &TdMessage) -> Option<ForwardInfo> {
message.forward_info.as_ref().map(|info| {
let sender_name = self.get_origin_sender_name(&info.origin);
ForwardInfo {
sender_name,
date: info.date,
}
})
}
/// Получает имя отправителя из MessageOrigin
fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String {
use tdlib_rs::enums::MessageOrigin;
match origin {
MessageOrigin::User(u) => {
self.user_names.peek(&u.sender_user_id)
.cloned()
.unwrap_or_else(|| format!("User_{}", u.sender_user_id))
}
MessageOrigin::Chat(c) => {
self.chats.iter()
.find(|chat| chat.id == c.sender_chat_id)
.map(|chat| chat.title.clone())
.unwrap_or_else(|| "Чат".to_string())
}
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
MessageOrigin::Channel(c) => {
self.chats.iter()
.find(|chat| chat.id == c.chat_id)
.map(|chat| chat.title.clone())
.unwrap_or_else(|| "Канал".to_string())
}
}
}
/// Обновляет reply info для сообщений, где данные не были загружены
/// Вызывается после загрузки истории, когда все сообщения уже в списке
fn update_reply_info_from_loaded_messages(&mut self) {
// Собираем данные для обновления (id -> (sender_name, content))
let msg_data: std::collections::HashMap<i64, (String, String)> = self
.current_chat_messages
.iter()
.map(|m| (m.id, (m.sender_name.clone(), m.content.clone())))
.collect();
// Обновляем reply_to для сообщений с неполными данными
for msg in &mut self.current_chat_messages {
if let Some(ref mut reply) = msg.reply_to {
// Если sender_name = "..." или text пустой — пробуем заполнить
if reply.sender_name == "..." || reply.text.is_empty() {
if let Some((sender, content)) = msg_data.get(&reply.message_id) {
if reply.sender_name == "..." {
reply.sender_name = sender.clone();
}
if reply.text.is_empty() {
reply.text = content.clone();
}
}
}
}
}
}
/// Асинхронно обновляет reply info, загружая недостающие сообщения
pub async fn fetch_missing_reply_info(&mut self) {
let chat_id = match self.current_chat_id {
Some(id) => id,
None => return,
};
// Собираем message_id для которых нужно загрузить данные
let missing_ids: Vec<i64> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.reply_to.as_ref().and_then(|reply| {
if reply.sender_name == "..." || reply.text.is_empty() {
Some(reply.message_id)
} else {
None
}
})
})
.collect();
if missing_ids.is_empty() {
return;
}
// Загружаем каждое сообщение и кэшируем данные
let mut reply_cache: std::collections::HashMap<i64, (String, String)> =
std::collections::HashMap::new();
for msg_id in missing_ids {
if reply_cache.contains_key(&msg_id) {
continue;
}
if let Ok(tdlib_rs::enums::Message::Message(msg)) =
functions::get_message(chat_id, msg_id, self.client_id).await
{
let sender_name = match &msg.sender_id {
tdlib_rs::enums::MessageSender::User(user) => {
self.user_names
.get(&user.user_id)
.cloned()
.unwrap_or_else(|| format!("User_{}", user.user_id))
}
tdlib_rs::enums::MessageSender::Chat(chat) => {
self.chats
.iter()
.find(|c| c.id == chat.chat_id)
.map(|c| c.title.clone())
.unwrap_or_else(|| "Чат".to_string())
}
};
let (content, _) = extract_message_text_static(&msg);
reply_cache.insert(msg_id, (sender_name, content));
}
}
// Применяем загруженные данные
for msg in &mut self.current_chat_messages {
if let Some(ref mut reply) = msg.reply_to {
if let Some((sender, content)) = reply_cache.get(&reply.message_id) {
if reply.sender_name == "..." {
reply.sender_name = sender.clone();
}
if reply.text.is_empty() {
reply.text = content.clone();
}
}
}
} }
} }
@@ -779,6 +1013,9 @@ impl TdClient {
all_messages.reverse(); all_messages.reverse();
self.current_chat_messages = all_messages.clone(); self.current_chat_messages = all_messages.clone();
// Обновляем reply info для сообщений где данные не были загружены
self.update_reply_info_from_loaded_messages();
// Отмечаем сообщения как прочитанные // Отмечаем сообщения как прочитанные
if !all_messages.is_empty() { if !all_messages.is_empty() {
let message_ids: Vec<i64> = all_messages.iter().map(|m| m.id).collect(); let message_ids: Vec<i64> = all_messages.iter().map(|m| m.id).collect();
@@ -860,10 +1097,10 @@ impl TdClient {
} }
} }
/// Отправка текстового сообщения с поддержкой Markdown /// Отправка текстового сообщения с поддержкой Markdown и reply
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<MessageInfo, String> { pub async fn send_message(&self, chat_id: i64, text: String, reply_to_message_id: Option<i64>, reply_info: Option<ReplyInfo>) -> Result<MessageInfo, String> {
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage};
use tdlib_rs::enums::{InputMessageContent, TextParseMode}; use tdlib_rs::enums::{InputMessageContent, TextParseMode, InputMessageReplyTo};
// Парсим markdown в тексте // Парсим markdown в тексте
let formatted_text = match functions::parse_text_entities( let formatted_text = match functions::parse_text_entities(
@@ -890,10 +1127,20 @@ impl TdClient {
clear_draft: true, clear_draft: true,
}); });
// Создаём reply_to если есть message_id для ответа
// chat_id: 0 означает ответ в том же чате
let reply_to = reply_to_message_id.map(|msg_id| {
InputMessageReplyTo::Message(InputMessageReplyToMessage {
chat_id: 0,
message_id: msg_id,
quote: None,
})
});
let result = functions::send_message( let result = functions::send_message(
chat_id, chat_id,
0, // message_thread_id 0, // message_thread_id
None, // reply_to reply_to,
None, // options None, // options
content, content,
self.client_id, self.client_id,
@@ -904,6 +1151,7 @@ impl TdClient {
Ok(tdlib_rs::enums::Message::Message(msg)) => { Ok(tdlib_rs::enums::Message::Message(msg)) => {
// Извлекаем текст и entities из отправленного сообщения // Извлекаем текст и entities из отправленного сообщения
let (content, entities) = extract_message_text_static(&msg); let (content, entities) = extract_message_text_static(&msg);
Ok(MessageInfo { Ok(MessageInfo {
id: msg.id, id: msg.id,
sender_name: "Вы".to_string(), sender_name: "Вы".to_string(),
@@ -916,6 +1164,8 @@ impl TdClient {
can_be_edited: msg.can_be_edited, can_be_edited: msg.can_be_edited,
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to: reply_info,
forward_from: None,
}) })
} }
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
@@ -975,6 +1225,8 @@ impl TdClient {
can_be_edited: msg.can_be_edited, can_be_edited: msg.can_be_edited,
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to: None, // При редактировании reply сохраняется из оригинала
forward_from: None, // При редактировании forward сохраняется из оригинала
}) })
} }
Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)),
@@ -998,6 +1250,26 @@ impl TdClient {
} }
} }
/// Пересылка сообщений
pub async fn forward_messages(&self, to_chat_id: i64, from_chat_id: i64, message_ids: Vec<i64>) -> Result<(), String> {
let result = functions::forward_messages(
to_chat_id,
0, // message_thread_id
from_chat_id,
message_ids,
None, // options
false, // send_copy
false, // remove_caption
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка пересылки сообщения: {:?}", e)),
}
}
/// Обработка очереди сообщений для отметки как прочитанных /// Обработка очереди сообщений для отметки как прочитанных
pub async fn process_pending_view_messages(&mut self) { pub async fn process_pending_view_messages(&mut self) {
let pending = std::mem::take(&mut self.pending_view_messages); let pending = std::mem::take(&mut self.pending_view_messages);
@@ -1125,3 +1397,33 @@ fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>)
_ => ("[Сообщение]".to_string(), vec![]), _ => ("[Сообщение]".to_string(), vec![]),
} }
} }
/// Извлекает текст из MessageContent (для reply preview)
fn extract_content_text(content: &MessageContent) -> String {
match content {
MessageContent::MessageText(text) => text.text.text.clone(),
MessageContent::MessagePhoto(photo) => {
if photo.caption.text.is_empty() {
"[Фото]".to_string()
} else {
format!("[Фото] {}", photo.caption.text)
}
}
MessageContent::MessageVideo(video) => {
if video.caption.text.is_empty() {
"[Видео]".to_string()
} else {
format!("[Видео] {}", video.caption.text)
}
}
MessageContent::MessageDocument(doc) => format!("[Файл: {}]", doc.document.file_name),
MessageContent::MessageVoiceNote(_) => "[Голосовое]".to_string(),
MessageContent::MessageVideoNote(_) => "[Видеосообщение]".to_string(),
MessageContent::MessageSticker(sticker) => format!("[Стикер: {}]", sticker.sticker.emoji),
MessageContent::MessageAnimation(_) => "[GIF]".to_string(),
MessageContent::MessageAudio(audio) => format!("[Аудио: {}]", audio.audio.title),
MessageContent::MessageCall(_) => "[Звонок]".to_string(),
MessageContent::MessagePoll(poll) => format!("[Опрос: {}]", poll.poll.question.text),
_ => "[Сообщение]".to_string(),
}
}

View File

@@ -83,8 +83,18 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
}) })
.collect(); .collect();
// Заголовок блока: обычный или режим пересылки
let block = if app.is_forwarding() {
Block::default()
.title(" ↪ Выберите чат ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
} else {
Block::default().borders(Borders::ALL)
};
let chats_list = List::new(items) let chats_list = List::new(items)
.block(Block::default().borders(Borders::ALL)) .block(block)
.highlight_style( .highlight_style(
Style::default() Style::default()
.add_modifier(Modifier::ITALIC) .add_modifier(Modifier::ITALIC)

View File

@@ -144,7 +144,7 @@ fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
} }
/// Рендерит текст инпута с блочным курсором /// Рендерит текст инпута с блочным курсором
fn render_input_with_cursor<'a>(prefix: &'a str, text: &str, cursor_pos: usize, color: Color) -> Line<'a> { fn render_input_with_cursor(prefix: &str, text: &str, cursor_pos: usize, color: Color) -> Line<'static> {
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())]; let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
@@ -435,6 +435,48 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let selection_marker = if is_selected { "" } else { "" }; let selection_marker = if is_selected { "" } else { "" };
let marker_len = selection_marker.chars().count(); let marker_len = selection_marker.chars().count();
// Отображаем forward если есть
if let Some(forward) = &msg.forward_from {
let forward_line = format!("↪ Переслано от {}", forward.sender_name);
let forward_len = forward_line.chars().count();
if msg.is_outgoing {
// Forward справа для исходящих
let padding = content_width.saturating_sub(forward_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
} else {
// Forward слева для входящих
lines.push(Line::from(vec![
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
}
}
// Отображаем reply если есть
if let Some(reply) = &msg.reply_to {
let reply_text: String = reply.text.chars().take(40).collect();
let ellipsis = if reply.text.chars().count() > 40 { "..." } else { "" };
let reply_line = format!("{}: {}{}", reply.sender_name, reply_text, ellipsis);
let reply_len = reply_line.chars().count();
if msg.is_outgoing {
// Reply справа для исходящих
let padding = content_width.saturating_sub(reply_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
// Reply слева для входящих
lines.push(Line::from(vec![
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
}
}
if msg.is_outgoing { if msg.is_outgoing {
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)" // Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
let read_mark = if msg.is_read { "✓✓" } else { "" }; let read_mark = if msg.is_read { "✓✓" } else { "" };
@@ -562,17 +604,29 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(messages_widget, message_chunks[1]); f.render_widget(messages_widget, message_chunks[1]);
// Input box с wrap для длинного текста и блочным курсором // Input box с wrap для длинного текста и блочным курсором
let (input_line, input_title) = if app.is_selecting_message() { let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app.get_forwarding_message()
.map(|m| {
let text_preview: String = m.content.chars().take(40).collect();
let ellipsis = if m.content.chars().count() > 40 { "..." } else { "" };
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей // Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message(); let selected_msg = app.get_selected_message();
let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false); let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false);
let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false); let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false);
let hint = match (can_edit, can_delete) { let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ выбрать · Enter редакт. · d удалить · Esc отмена", (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc",
(true, false) => "↑↓ выбрать · Enter редакт. · Esc отмена", (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc",
(false, true) => "↑↓ выбрать · d удалить · Esc отмена", (false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc",
(false, false) => "↑↓ выбрать · Esc отмена", (false, false) => "↑↓ · r ответить · f переслать · Esc",
}; };
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ") (Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
} else if app.is_editing() { } else if app.is_editing() {
@@ -590,6 +644,31 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let line = render_input_with_cursor("", &app.message_input, app.cursor_position, Color::Magenta); let line = render_input_with_cursor("", &app.message_input, app.cursor_position, Color::Magenta);
(line, " Редактирование (Esc отмена) ") (line, " Редактирование (Esc отмена) ")
} }
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing { "Вы" } else { &m.sender_name };
let text_preview: String = m.content.chars().take(30).collect();
let ellipsis = if m.content.chars().count() > 30 { "..." } else { "" };
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(&prefix, &app.message_input, app.cursor_position, Color::Yellow);
(line, " Ответ (Esc отмена) ")
}
} else { } else {
// Обычный режим // Обычный режим
if app.message_input.is_empty() { if app.message_input.is_empty() {
@@ -610,10 +689,15 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let input_block = if input_title.is_empty() { let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL) Block::default().borders(Borders::ALL)
} else { } else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.title(input_title) .title(input_title)
.title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) .title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD))
}; };
let input = Paragraph::new(input_line) let input = Paragraph::new(input_line)