Merge pull request 'yet-another-changes' (#8) from yet-another-changes into main
Reviewed-on: #8
This commit is contained in:
11
CONTEXT.md
11
CONTEXT.md
@@ -32,6 +32,9 @@
|
||||
- **Отправка текстовых сообщений**
|
||||
- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
|
||||
- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения
|
||||
- **Reply на сообщения**: в режиме выбора нажать `r` / `к` → режим ответа с превью
|
||||
- **Forward сообщений**: в режиме выбора нажать `f` / `а` → выбор чата для пересылки
|
||||
- **Отображение пересланных сообщений**: индикатор "↪ Переслано от" с именем отправителя
|
||||
- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений
|
||||
- **Новые сообщения в реальном времени** при открытом чате
|
||||
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
|
||||
@@ -78,10 +81,12 @@
|
||||
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
|
||||
- `↑` при пустом инпуте — выбор сообщения для редактирования
|
||||
- `Enter` в режиме выбора — начать редактирование
|
||||
- `r` / `к` в режиме выбора — ответить на сообщение (reply)
|
||||
- `f` / `а` в режиме выбора — переслать сообщение (forward)
|
||||
- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением)
|
||||
- `y` / `н` / `Enter` — подтвердить удаление в модалке
|
||||
- `n` / `т` / `Esc` — отменить удаление в модалке
|
||||
- `Esc` — отменить выбор/редактирование
|
||||
- `Esc` — отменить выбор/редактирование/reply
|
||||
- `1-9` — переключение папок (в списке чатов)
|
||||
- **Редактирование текста в инпуте:**
|
||||
- `←` / `→` — перемещение курсора
|
||||
@@ -166,8 +171,8 @@ API_HASH=your_api_hash
|
||||
- [x] Markdown форматирование в сообщениях
|
||||
- [x] Редактирование сообщений
|
||||
- [x] Удаление сообщений
|
||||
- [ ] Reply на сообщения
|
||||
- [ ] Forward сообщений
|
||||
- [x] Reply на сообщения
|
||||
- [x] Forward сообщений
|
||||
|
||||
## Известные проблемы
|
||||
|
||||
|
||||
12
ROADMAP.md
12
ROADMAP.md
@@ -101,5 +101,13 @@
|
||||
- Vim-style курсор █
|
||||
- Перемещение ←/→, Home/End
|
||||
- Редактирование в любой позиции
|
||||
- [ ] Reply на сообщения
|
||||
- [ ] Forward сообщений
|
||||
- [x] Reply на сообщения
|
||||
- `r` / `к` в режиме выбора → режим ответа
|
||||
- Превью сообщения в поле ввода
|
||||
- Esc для отмены
|
||||
- [x] Forward сообщений
|
||||
- `f` / `а` в режиме выбора → режим пересылки
|
||||
- Превью сообщения в поле ввода
|
||||
- Выбор чата стрелками, Enter для пересылки
|
||||
- Esc для отмены
|
||||
- Отображение "↪ Переслано от" для пересланных сообщений
|
||||
|
||||
@@ -39,6 +39,14 @@ pub struct App {
|
||||
// Delete confirmation
|
||||
/// ID сообщения для подтверждения удаления (показывает модалку)
|
||||
pub confirm_delete_message_id: Option<i64>,
|
||||
// Reply state
|
||||
/// ID сообщения, на которое отвечаем (None = обычная отправка)
|
||||
pub replying_to_message_id: Option<i64>,
|
||||
// Forward state
|
||||
/// ID сообщения для пересылки
|
||||
pub forwarding_message_id: Option<i64>,
|
||||
/// Режим выбора чата для пересылки
|
||||
pub is_selecting_forward_chat: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -68,6 +76,9 @@ impl App {
|
||||
editing_message_id: None,
|
||||
selected_message_index: None,
|
||||
confirm_delete_message_id: None,
|
||||
replying_to_message_id: None,
|
||||
forwarding_message_id: None,
|
||||
is_selecting_forward_chat: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +134,7 @@ impl App {
|
||||
self.message_scroll_offset = 0;
|
||||
self.editing_message_id = None;
|
||||
self.selected_message_index = None;
|
||||
self.replying_to_message_id = None;
|
||||
// Очищаем данные в TdClient
|
||||
self.td_client.current_chat_id = None;
|
||||
self.td_client.current_chat_messages.clear();
|
||||
@@ -306,4 +318,62 @@ impl App {
|
||||
pub fn is_confirm_delete_shown(&self) -> bool {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,51 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
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 {
|
||||
match key.code {
|
||||
@@ -81,7 +126,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
app.message_scroll_offset = 0;
|
||||
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
||||
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;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
@@ -161,11 +207,21 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
} 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.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)) => {
|
||||
// Добавляем отправленное сообщение в список (с лимитом)
|
||||
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;
|
||||
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
||||
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;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
@@ -211,7 +268,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Esc - отменить выбор/редактирование или закрыть чат
|
||||
// Esc - отменить выбор/редактирование/reply или закрыть чат
|
||||
if key.code == KeyCode::Esc {
|
||||
if app.is_selecting_message() {
|
||||
// Отменить выбор сообщения
|
||||
@@ -219,6 +276,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||
} else if app.is_editing() {
|
||||
// Отменить редактирование
|
||||
app.cancel_editing();
|
||||
} else if app.is_replying() {
|
||||
// Отменить режим ответа
|
||||
app.cancel_reply();
|
||||
} else if app.selected_chat_id.is_some() {
|
||||
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;
|
||||
|
||||
@@ -113,6 +113,26 @@ pub struct ChatInfo {
|
||||
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)]
|
||||
pub struct MessageInfo {
|
||||
pub id: i64,
|
||||
@@ -131,6 +151,10 @@ pub struct MessageInfo {
|
||||
pub can_be_deleted_only_for_self: bool,
|
||||
/// Можно ли удалить для всех
|
||||
pub can_be_deleted_for_all_users: bool,
|
||||
/// Информация о reply (если это ответ на сообщение)
|
||||
pub reply_to: Option<ReplyInfo>,
|
||||
/// Информация о forward (если сообщение переслано)
|
||||
pub forward_from: Option<ForwardInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -240,7 +264,17 @@ impl TdClient {
|
||||
}
|
||||
|
||||
/// Добавляет сообщение в текущий чат с соблюдением лимита
|
||||
/// Если сообщение с таким id уже есть — заменяет его (сохраняя reply_to)
|
||||
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);
|
||||
// Ограничиваем количество сообщений (удаляем старые)
|
||||
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
||||
@@ -389,8 +423,27 @@ impl TdClient {
|
||||
let msg_info = self.convert_message(&new_msg.message, chat_id);
|
||||
let msg_id = msg_info.id;
|
||||
let is_incoming = !msg_info.is_outgoing;
|
||||
// Проверяем, что сообщение ещё не добавлено (по id)
|
||||
if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) {
|
||||
|
||||
// Проверяем, есть ли уже сообщение с таким id
|
||||
let existing_idx = self.current_chat_messages.iter().position(|m| m.id == msg_info.id);
|
||||
|
||||
match existing_idx {
|
||||
Some(idx) => {
|
||||
// Сообщение уже есть - обновляем
|
||||
if is_incoming {
|
||||
self.current_chat_messages[idx] = msg_info;
|
||||
} else {
|
||||
// Для исходящих: обновляем can_be_edited и другие поля,
|
||||
// но сохраняем reply_to (добавленный при отправке)
|
||||
let existing = &mut self.current_chat_messages[idx];
|
||||
existing.can_be_edited = msg_info.can_be_edited;
|
||||
existing.can_be_deleted_only_for_self = msg_info.can_be_deleted_only_for_self;
|
||||
existing.can_be_deleted_for_all_users = msg_info.can_be_deleted_for_all_users;
|
||||
existing.is_read = msg_info.is_read;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Нового сообщения нет - добавляем
|
||||
self.push_message(msg_info);
|
||||
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
||||
if is_incoming {
|
||||
@@ -399,6 +452,7 @@ impl TdClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Update::User(update) => {
|
||||
// Сохраняем имя и username пользователя
|
||||
let user = update.user;
|
||||
@@ -630,6 +684,12 @@ impl TdClient {
|
||||
|
||||
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 {
|
||||
id: message.id,
|
||||
sender_name,
|
||||
@@ -642,6 +702,187 @@ impl TdClient {
|
||||
can_be_edited: message.can_be_edited,
|
||||
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,
|
||||
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 +1020,9 @@ impl TdClient {
|
||||
all_messages.reverse();
|
||||
self.current_chat_messages = all_messages.clone();
|
||||
|
||||
// Обновляем reply info для сообщений где данные не были загружены
|
||||
self.update_reply_info_from_loaded_messages();
|
||||
|
||||
// Отмечаем сообщения как прочитанные
|
||||
if !all_messages.is_empty() {
|
||||
let message_ids: Vec<i64> = all_messages.iter().map(|m| m.id).collect();
|
||||
@@ -860,10 +1104,10 @@ impl TdClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправка текстового сообщения с поддержкой Markdown
|
||||
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<MessageInfo, String> {
|
||||
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown};
|
||||
use tdlib_rs::enums::{InputMessageContent, TextParseMode};
|
||||
/// Отправка текстового сообщения с поддержкой Markdown и reply
|
||||
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, InputMessageReplyToMessage};
|
||||
use tdlib_rs::enums::{InputMessageContent, TextParseMode, InputMessageReplyTo};
|
||||
|
||||
// Парсим markdown в тексте
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
@@ -890,10 +1134,20 @@ impl TdClient {
|
||||
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(
|
||||
chat_id,
|
||||
0, // message_thread_id
|
||||
None, // reply_to
|
||||
reply_to,
|
||||
None, // options
|
||||
content,
|
||||
self.client_id,
|
||||
@@ -904,6 +1158,7 @@ impl TdClient {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||
// Извлекаем текст и entities из отправленного сообщения
|
||||
let (content, entities) = extract_message_text_static(&msg);
|
||||
|
||||
Ok(MessageInfo {
|
||||
id: msg.id,
|
||||
sender_name: "Вы".to_string(),
|
||||
@@ -916,6 +1171,8 @@ impl TdClient {
|
||||
can_be_edited: msg.can_be_edited,
|
||||
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,
|
||||
reply_to: reply_info,
|
||||
forward_from: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
||||
@@ -975,6 +1232,8 @@ impl TdClient {
|
||||
can_be_edited: msg.can_be_edited,
|
||||
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,
|
||||
reply_to: None, // При редактировании reply сохраняется из оригинала
|
||||
forward_from: None, // При редактировании forward сохраняется из оригинала
|
||||
})
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)),
|
||||
@@ -998,6 +1257,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) {
|
||||
let pending = std::mem::take(&mut self.pending_view_messages);
|
||||
@@ -1125,3 +1404,33 @@ fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>)
|
||||
_ => ("[Сообщение]".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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +83,18 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
})
|
||||
.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)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.block(block)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::ITALIC)
|
||||
|
||||
@@ -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 mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
|
||||
|
||||
@@ -352,10 +352,17 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
// ID выбранного сообщения для подсветки
|
||||
let selected_msg_id = app.get_selected_message().map(|m| m.id);
|
||||
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
||||
let mut selected_msg_line: Option<usize> = None;
|
||||
|
||||
for msg in &app.td_client.current_chat_messages {
|
||||
// Проверяем, выбрано ли это сообщение
|
||||
let is_selected = selected_msg_id == Some(msg.id);
|
||||
|
||||
// Запоминаем строку начала выбранного сообщения
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
// Проверяем, нужно ли добавить разделитель даты
|
||||
let msg_day = get_day(msg.date);
|
||||
if last_day != Some(msg_day) {
|
||||
@@ -435,6 +442,48 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let selection_marker = if is_selected { "▶ " } else { "" };
|
||||
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 {
|
||||
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
|
||||
let read_mark = if msg.is_read { "✓✓" } else { "✓" };
|
||||
@@ -548,13 +597,33 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let visible_height = message_chunks[1].height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
|
||||
// Базовый скролл (показываем последние сообщения)
|
||||
let base_scroll = if total_lines > visible_height {
|
||||
total_lines - visible_height
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let scroll_offset = base_scroll.saturating_sub(app.message_scroll_offset) as u16;
|
||||
// Если выбрано сообщение, автоскроллим к нему
|
||||
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))
|
||||
@@ -562,17 +631,29 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
f.render_widget(messages_widget, message_chunks[1]);
|
||||
|
||||
// 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 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 hint = match (can_edit, can_delete) {
|
||||
(true, true) => "↑↓ выбрать · Enter редакт. · d удалить · Esc отмена",
|
||||
(true, false) => "↑↓ выбрать · Enter редакт. · Esc отмена",
|
||||
(false, true) => "↑↓ выбрать · d удалить · Esc отмена",
|
||||
(false, false) => "↑↓ выбрать · Esc отмена",
|
||||
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc",
|
||||
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc",
|
||||
(false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc",
|
||||
(false, false) => "↑↓ · r ответить · f переслать · Esc",
|
||||
};
|
||||
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
|
||||
} else if app.is_editing() {
|
||||
@@ -590,6 +671,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);
|
||||
(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 {
|
||||
// Обычный режим
|
||||
if app.message_input.is_empty() {
|
||||
@@ -610,10 +716,15 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let input_block = if input_title.is_empty() {
|
||||
Block::default().borders(Borders::ALL)
|
||||
} else {
|
||||
let title_color = if app.is_replying() || app.is_forwarding() {
|
||||
Color::Cyan
|
||||
} else {
|
||||
Color::Magenta
|
||||
};
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.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)
|
||||
|
||||
Reference in New Issue
Block a user