This commit is contained in:
Mikhail Kilin
2026-01-27 23:29:00 +03:00
parent 356d2d3064
commit f291191577
8 changed files with 923 additions and 43 deletions

View File

@@ -75,8 +75,18 @@ pub struct App {
pub leave_group_confirmation_step: u8,
/// Информация профиля для отображения
pub profile_info: Option<crate::tdlib::ProfileInfo>,
// Reaction picker mode
/// Режим выбора реакции
pub is_reaction_picker_mode: bool,
/// ID сообщения для добавления реакции
pub selected_message_for_reaction: Option<i64>,
/// Список доступных реакций
pub available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
pub selected_reaction_index: usize,
}
impl App {
pub fn new() -> App {
let mut state = ListState::default();
@@ -119,6 +129,10 @@ impl App {
selected_profile_action: 0,
leave_group_confirmation_step: 0,
profile_info: None,
is_reaction_picker_mode: false,
selected_message_for_reaction: None,
available_reactions: Vec::new(),
selected_reaction_index: 0,
}
}
@@ -606,4 +620,44 @@ impl App {
pub fn get_leave_group_confirmation_step(&self) -> u8 {
self.leave_group_confirmation_step
}
// ========== Reaction Picker ==========
pub fn is_reaction_picker_mode(&self) -> bool {
self.is_reaction_picker_mode
}
pub fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
self.is_reaction_picker_mode = true;
self.selected_message_for_reaction = Some(message_id);
self.available_reactions = available_reactions;
self.selected_reaction_index = 0;
}
pub fn exit_reaction_picker_mode(&mut self) {
self.is_reaction_picker_mode = false;
self.selected_message_for_reaction = None;
self.available_reactions.clear();
self.selected_reaction_index = 0;
}
pub fn select_previous_reaction(&mut self) {
if !self.available_reactions.is_empty() && self.selected_reaction_index > 0 {
self.selected_reaction_index -= 1;
}
}
pub fn select_next_reaction(&mut self) {
if self.selected_reaction_index + 1 < self.available_reactions.len() {
self.selected_reaction_index += 1;
}
}
pub fn get_selected_reaction(&self) -> Option<&String> {
self.available_reactions.get(self.selected_reaction_index)
}
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.selected_message_for_reaction
}
}

View File

@@ -251,6 +251,73 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return;
}
// Обработка ввода в режиме выбора реакции
if app.is_reaction_picker_mode() {
match key.code {
KeyCode::Left => {
app.select_previous_reaction();
app.needs_redraw = true;
}
KeyCode::Right => {
app.select_next_reaction();
app.needs_redraw = true;
}
KeyCode::Up => {
// Переход на ряд выше (8 эмодзи в ряду)
if app.selected_reaction_index >= 8 {
app.selected_reaction_index = app.selected_reaction_index.saturating_sub(8);
app.needs_redraw = true;
}
}
KeyCode::Down => {
// Переход на ряд ниже (8 эмодзи в ряду)
let new_index = app.selected_reaction_index + 8;
if new_index < app.available_reactions.len() {
app.selected_reaction_index = new_index;
app.needs_redraw = true;
}
}
KeyCode::Enter => {
// Добавить/убрать реакцию
if let Some(emoji) = app.get_selected_reaction().cloned() {
if let Some(message_id) = app.get_selected_message_for_reaction() {
if let Some(chat_id) = app.selected_chat_id {
app.status_message = Some("Отправка реакции...".to_string());
app.needs_redraw = true;
match timeout(
Duration::from_secs(5),
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone())
).await {
Ok(Ok(_)) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
Ok(Err(e)) => {
app.error_message = Some(format!("Ошибка: {}", e));
app.status_message = None;
app.needs_redraw = true;
}
Err(_) => {
app.error_message = Some("Таймаут отправки реакции".to_string());
app.status_message = None;
app.needs_redraw = true;
}
}
}
}
}
}
KeyCode::Esc => {
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
_ => {}
}
return;
}
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
match key.code {
@@ -563,6 +630,58 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Начать режим пересылки
app.start_forward_selected();
}
KeyCode::Char('y') | KeyCode::Char('н') => {
// Копировать сообщение
if let Some(msg) = app.get_selected_message() {
let text = format_message_for_clipboard(msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
}
Err(e) => {
app.error_message = Some(format!("Ошибка копирования: {}", e));
}
}
}
}
KeyCode::Char('e') | KeyCode::Char('у') => {
// Открыть emoji picker для добавления реакции
if let Some(msg) = app.get_selected_message() {
let chat_id = app.selected_chat_id.unwrap();
let message_id = msg.id;
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
// Запрашиваем доступные реакции
match timeout(
Duration::from_secs(5),
app.td_client.get_message_available_reactions(chat_id, message_id)
).await {
Ok(Ok(reactions)) => {
if reactions.is_empty() {
app.error_message = Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id, reactions);
app.status_message = None;
app.needs_redraw = true;
}
}
Ok(Err(e)) => {
app.error_message = Some(format!("Ошибка загрузки реакций: {}", e));
app.status_message = None;
app.needs_redraw = true;
}
Err(_) => {
app.error_message = Some("Таймаут загрузки реакций".to_string());
app.status_message = None;
app.needs_redraw = true;
}
}
}
}
_ => {}
}
return;
@@ -756,3 +875,91 @@ fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
count
}
/// Копирует текст в системный буфер обмена
fn copy_to_clipboard(text: &str) -> Result<(), String> {
use arboard::Clipboard;
let mut clipboard = Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
clipboard.set_text(text).map_err(|e| format!("Не удалось скопировать: {}", e))?;
Ok(())
}
/// Форматирует сообщение для копирования с контекстом
fn format_message_for_clipboard(msg: &crate::tdlib::client::MessageInfo) -> String {
let mut result = String::new();
// Добавляем forward контекст если есть
if let Some(forward) = &msg.forward_from {
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
}
// Добавляем reply контекст если есть
if let Some(reply) = &msg.reply_to {
result.push_str(&format!("{}: {}\n", reply.sender_name, reply.text));
}
// Добавляем основной текст с markdown форматированием
result.push_str(&convert_entities_to_markdown(&msg.content, &msg.entities));
result
}
/// Конвертирует текст с entities в markdown
fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String {
use tdlib_rs::enums::TextEntityType;
if entities.is_empty() {
return text.to_string();
}
// Создаём вектор символов для работы с unicode
let chars: Vec<char> = text.chars().collect();
let mut result = String::new();
let mut i = 0;
while i < chars.len() {
// Ищем entity, который начинается в текущей позиции
let mut entity_found = false;
for entity in entities {
if entity.offset as usize == i {
entity_found = true;
let end = (entity.offset + entity.length) as usize;
let entity_text: String = chars[i..end.min(chars.len())].iter().collect();
// Применяем форматирование в зависимости от типа
let formatted = match &entity.r#type {
TextEntityType::Bold => format!("**{}**", entity_text),
TextEntityType::Italic => format!("*{}*", entity_text),
TextEntityType::Underline => format!("__{}__", entity_text),
TextEntityType::Strikethrough => format!("~~{}~~", entity_text),
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
format!("`{}`", entity_text)
}
TextEntityType::TextUrl(url_info) => {
format!("[{}]({})", entity_text, url_info.url)
}
TextEntityType::Url => format!("<{}>", entity_text),
TextEntityType::Mention | TextEntityType::MentionName(_) => {
format!("@{}", entity_text.trim_start_matches('@'))
}
TextEntityType::Spoiler => format!("||{}||", entity_text),
_ => entity_text,
};
result.push_str(&formatted);
i = end;
break;
}
}
if !entity_found {
result.push(chars[i]);
i += 1;
}
}
result
}

View File

@@ -137,6 +137,17 @@ pub struct ForwardInfo {
pub date: i32,
}
/// Информация о реакции на сообщение
#[derive(Debug, Clone)]
pub struct ReactionInfo {
/// Эмодзи реакции (например, "👍")
pub emoji: String,
/// Количество людей, поставивших эту реакцию
pub count: i32,
/// Поставил ли текущий пользователь эту реакцию
pub is_chosen: bool,
}
#[derive(Debug, Clone)]
pub struct MessageInfo {
pub id: i64,
@@ -159,6 +170,8 @@ pub struct MessageInfo {
pub reply_to: Option<ReplyInfo>,
/// Информация о forward (если сообщение переслано)
pub forward_from: Option<ForwardInfo>,
/// Реакции на сообщение
pub reactions: Vec<ReactionInfo>,
}
#[derive(Debug, Clone)]
@@ -623,6 +636,37 @@ impl TdClient {
});
}
}
Update::MessageInteractionInfo(update) => {
// Обновляем реакции в текущем открытом чате
if Some(update.chat_id) == self.current_chat_id {
if let Some(msg) = self.current_chat_messages.iter_mut().find(|m| m.id == update.message_id) {
// Извлекаем реакции из interaction_info
msg.reactions = update
.interaction_info
.as_ref()
.and_then(|info| info.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|reaction| {
let emoji = match &reaction.r#type {
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
};
Some(ReactionInfo {
emoji,
count: reaction.total_count,
is_chosen: reaction.is_chosen,
})
})
.collect()
})
.unwrap_or_default();
}
}
}
_ => {}
}
}
@@ -789,6 +833,9 @@ impl TdClient {
// Извлекаем информацию о forward
let forward_from = self.extract_forward_info(message);
// Извлекаем реакции
let reactions = self.extract_reactions(message);
MessageInfo {
id: message.id,
sender_name,
@@ -803,6 +850,7 @@ impl TdClient {
can_be_deleted_for_all_users: message.can_be_deleted_for_all_users,
reply_to,
forward_from,
reactions,
}
}
@@ -859,6 +907,34 @@ impl TdClient {
})
}
/// Извлекает информацию о реакциях из сообщения
fn extract_reactions(&self, message: &TdMessage) -> Vec<ReactionInfo> {
message
.interaction_info
.as_ref()
.and_then(|info| info.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|reaction| {
// Извлекаем эмодзи из ReactionType
let emoji = match &reaction.r#type {
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, // Пока игнорируем custom emoji
};
Some(ReactionInfo {
emoji,
count: reaction.total_count,
is_chosen: reaction.is_chosen,
})
})
.collect()
})
.unwrap_or_default()
}
/// Получает имя отправителя из MessageOrigin
fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String {
use tdlib_rs::enums::MessageOrigin;
@@ -1504,12 +1580,96 @@ impl TdClient {
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to: reply_info,
forward_from: None,
reactions: Vec::new(),
})
}
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
}
}
/// Получить доступные реакции для сообщения
pub async fn get_message_available_reactions(
&mut self,
chat_id: i64,
message_id: i64,
) -> Result<Vec<String>, String> {
use tdlib_rs::functions;
let result = functions::get_message_available_reactions(
chat_id,
message_id,
8, // row_size - количество реакций в ряду
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::AvailableReactions::AvailableReactions(reactions)) => {
// Извлекаем эмодзи из доступных реакций
// Используем top_reactions (самые популярные реакции)
let mut emojis: Vec<String> = reactions
.top_reactions
.iter()
.filter_map(|reaction| {
if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type {
Some(e.emoji.clone())
} else {
None
}
})
.collect();
// Если top_reactions пустой, используем popular_reactions
if emojis.is_empty() {
emojis = reactions
.popular_reactions
.iter()
.filter_map(|reaction| {
if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type {
Some(e.emoji.clone())
} else {
None
}
})
.collect();
}
Ok(emojis)
}
Err(e) => Err(format!("Ошибка получения реакций: {:?}", e)),
}
}
/// Добавить реакцию на сообщение (или убрать, если уже поставлена)
pub async fn toggle_reaction(
&mut self,
chat_id: i64,
message_id: i64,
emoji: String,
) -> Result<(), String> {
use tdlib_rs::functions;
use tdlib_rs::types::ReactionTypeEmoji;
use tdlib_rs::enums::ReactionType;
let reaction_type = ReactionType::Emoji(ReactionTypeEmoji { emoji });
let result = functions::add_message_reaction(
chat_id,
message_id,
reaction_type,
false, // is_big - обычная реакция (не "большая" анимация)
true, // update_recent_reactions - обновить список недавних реакций
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка добавления реакции: {:?}", e)),
}
}
/// Редактирование текстового сообщения с поддержкой Markdown
/// Устанавливает черновик для чата через TDLib API
pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> {
@@ -1616,6 +1776,7 @@ impl TdClient {
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to: None, // При редактировании reply сохраняется из оригинала
forward_from: None, // При редактировании forward сохраняется из оригинала
reactions: Vec::new(), // При редактировании реакции сохраняются из оригинала
})
}
Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)),

View File

@@ -668,6 +668,58 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
}
}
// Отображаем реакции под сообщением
if !msg.reactions.is_empty() {
let mut reaction_spans = vec![];
for reaction in &msg.reactions {
if !reaction_spans.is_empty() {
reaction_spans.push(Span::raw(" "));
}
// Свои реакции в рамках [emoji], чужие просто emoji
let reaction_text = if reaction.is_chosen {
if reaction.count > 1 {
format!("[{}] {}", reaction.emoji, reaction.count)
} else {
format!("[{}]", reaction.emoji)
}
} else {
if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
}
};
let style = if reaction.is_chosen {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Gray)
};
reaction_spans.push(Span::styled(reaction_text, style));
}
// Выравниваем реакции в зависимости от типа сообщения
if msg.is_outgoing {
// Реакции справа для исходящих
let reactions_text: String = reaction_spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join(" ");
let reactions_len = reactions_text.chars().count();
let padding = content_width.saturating_sub(reactions_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
line_spans.extend(reaction_spans);
lines.push(Line::from(line_spans));
} else {
// Реакции слева для входящих
lines.push(Line::from(reaction_spans));
}
}
}
if lines.is_empty() {
@@ -734,10 +786,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
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 ред. · r ответ · f перслть · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc",
(false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · Esc",
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
} else if app.is_editing() {
@@ -827,6 +879,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area);
}
// Модалка выбора реакции
if app.is_reaction_picker_mode() {
render_reaction_picker_modal(f, area, &app.available_reactions, app.selected_reaction_index);
}
}
/// Рендерит режим поиска по сообщениям
@@ -1136,3 +1193,78 @@ fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
f.render_widget(modal, modal_area);
}
/// Рендерит модалку выбора реакции
fn render_reaction_picker_modal(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) {
use ratatui::widgets::Clear;
// Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
// Центрируем модалку
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
// Формируем содержимое - сетка эмодзи
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
for row in 0..rows {
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
for col in 0..emojis_per_row {
let idx = row * emojis_per_row + col;
if idx >= available_reactions.len() {
break;
}
let emoji = &available_reactions[idx];
let is_selected = idx == selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(Color::White)
};
row_spans.push(Span::styled(format!(" {} ", emoji), style));
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
}
text_lines.push(Line::from(row_spans));
}
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(" [←/→/↑/↓] ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw("Выбор "),
Span::styled(" [Enter] ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));
let modal = Paragraph::new(text_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
)
.alignment(Alignment::Left);
f.render_widget(modal, modal_area);
}